From d3b743c3c1542b03467196abe8850a91886c6f94 Mon Sep 17 00:00:00 2001 From: kappa Date: Wed, 28 Jan 2026 20:24:54 +0900 Subject: [PATCH] fix: server recommendation issues and __DIRECT__ tag visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix USD price display: all prices now show in KRW (₩) - Add Korea region auto-detection: extracts region preference from user messages - Fix low-spec recommendation for high-performance requirements: - Add extractTechStack() to detect PostgreSQL, Redis, MongoDB keywords - Enhance inferExpectedUsers() to consider tech stack complexity - SaaS/B2B services now recommend 4GB+ RAM servers - Fix __DIRECT__ tag appearing in output: - Reorder message concatenation in server-agent.ts - Add stripping logic in conversation-service.ts and api.ts Co-Authored-By: Claude Opus 4.5 --- src/routes/api.ts | 223 ++++++- src/server-agent.ts | 424 ++++++++++--- src/services/conversation-service.ts | 25 +- src/tools/server-tool.ts | 910 +++++++++++++++++++++++---- src/utils/formatters.ts | 10 +- 5 files changed, 1363 insertions(+), 229 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 0ba7b43..969aa5a 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -284,8 +284,8 @@ async function handleDepositDeduct(request: Request, env: Env): Promise { - // 프로덕션 환경에서는 비활성화 - if (env.ENVIRONMENT === 'production') { + // 개발/테스트 환경에서만 활성화 (명시적 설정 필수) + if (env.ENVIRONMENT !== 'development' && env.ENVIRONMENT !== 'test') { return new Response('Not Found', { status: 404 }); } @@ -310,8 +310,8 @@ async function handleTestApi(request: Request, env: Env): Promise { const body = parseResult.data; - // 간단한 인증 - if (body.secret !== env.WEBHOOK_SECRET) { + // 인증 (Timing-safe comparison 사용) + if (!timingSafeEqual(body.secret || '', env.WEBHOOK_SECRET || '')) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -339,6 +339,12 @@ async function handleTestApi(request: Request, env: Env): Promise { // 2. AI 응답 생성 responseText = await generateAIResponse(env, userId, chatIdStr, body.text, telegramUserId); + // __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비) + if (responseText.includes('__DIRECT__')) { + const directIndex = responseText.indexOf('__DIRECT__'); + responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); + } + // 3. 봇 응답 버퍼에 추가 await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); @@ -417,6 +423,209 @@ async function handleChatApi(request: Request, env: Env): Promise { let responseText: string; + // 서버 삭제 확인 처리 (텍스트 기반) + if (body.message.trim() === '삭제') { + const deleteSessionKey = `delete_confirm:${telegramUserId}`; + const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey); + + if (deleteSessionData) { + try { + const { orderId } = JSON.parse(deleteSessionData); + + // Import and execute server deletion + const { executeServerDelete } = await import('../tools/server-tool'); + const result = await executeServerDelete(orderId, telegramUserId, env); + + // Delete session after execution + await env.SESSION_KV.delete(deleteSessionKey); + + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: result.message, + processing_time_ms: processingTimeMs, + }); + } catch (error) { + logger.error('Chat API - 서버 삭제 처리 오류', toError(error)); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.', + processing_time_ms: processingTimeMs, + }); + } + } + } + + // 서버 삭제 취소 처리 (다른 메시지 입력 시) + const deleteSessionKey = `delete_confirm:${telegramUserId}`; + const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey); + + if (deleteSessionData && body.message.trim() !== '삭제') { + try { + const { label } = JSON.parse(deleteSessionData); + await env.SESSION_KV.delete(deleteSessionKey); + + // Don't show cancellation message if it's a command (let command handler process it) + if (!body.message.startsWith('/')) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`, + processing_time_ms: processingTimeMs, + }); + } + } catch (error) { + logger.error('Chat API - 삭제 세션 취소 오류', toError(error)); + } + } + + // 서버 신청 확인 처리 (텍스트 기반) - Queue 기반 + if (body.message.trim() === '신청') { + const orderSessionKey = `server_order_confirm:${telegramUserId}`; + logger.info('신청 세션 확인', { orderSessionKey, telegramUserId }); + const orderSessionData = await env.SESSION_KV.get(orderSessionKey); + logger.info('신청 세션 데이터', { found: !!orderSessionData, data: orderSessionData?.slice(0, 100) }); + + if (orderSessionData) { + try { + const orderData = JSON.parse(orderSessionData); + + // 1. 서버 세션에서 가격 정보 가져오기 + const { getServerSession, deleteServerSession } = await import('../server-agent'); + const session = await getServerSession(env.DB, telegramUserId); + + if (!session || !session.lastRecommendation) { + await env.SESSION_KV.delete(orderSessionKey); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.', + processing_time_ms: processingTimeMs, + }); + } + + const selected = session.lastRecommendation.recommendations[orderData.index]; + if (!selected) { + await env.SESSION_KV.delete(orderSessionKey); + await deleteServerSession(env.DB, telegramUserId); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '❌ 선택한 서버를 찾을 수 없습니다.', + processing_time_ms: processingTimeMs, + }); + } + + const price = selected.price?.monthly_krw || 0; + + // 2. 잔액 확인 + const deposit = await env.DB.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(userId).first<{ balance: number }>(); + + if (!deposit || deposit.balance < price) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: + `❌ 잔액이 부족합니다.\n\n` + + `• 서버 가격: ${price.toLocaleString()}원/월\n` + + `• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n` + + `• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원\n\n` + + `잔액을 충전 후 다시 시도해주세요.`, + processing_time_ms: processingTimeMs, + }); + } + + // 3. Queue 확인 + if (!env.SERVER_PROVISION_QUEUE) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.', + processing_time_ms: processingTimeMs, + }); + } + + // 4. 주문 생성 및 Queue 전송 + const { createServerOrder, sendProvisionMessage } = await import('../server-provision'); + + const orderId = await createServerOrder( + env.DB, + userId, + telegramUserId, + selected.pricing_id, + selected.region.code, + 'anvil', + price, + `${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}` + ); + + await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId); + + // 5. 세션 정리 + await env.SESSION_KV.delete(orderSessionKey); + await deleteServerSession(env.DB, telegramUserId); + + // 6. 즉시 응답 + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: + `📋 서버 주문 접수 완료! (주문 #${orderId})\n\n` + + `• 서버: ${selected.plan_name}\n` + + `• 리전: ${selected.region.name} (${selected.region.code})\n` + + `• 가격: ${price.toLocaleString()}원/월\n\n` + + `⏳ 서버를 생성하고 있습니다... (1-2분 소요)\n` + + `완료되면 메시지로 알려드릴게요.`, + processing_time_ms: processingTimeMs, + }); + } catch (error) { + logger.error('Chat API - 서버 신청 처리 오류', toError(error)); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '🚫 서버 신청 중 오류가 발생했습니다. 다시 시도해주세요.', + processing_time_ms: processingTimeMs, + }); + } + } + } + + // 서버 신청 취소 처리 (다른 메시지 입력 시) + const orderSessionKey = `server_order_confirm:${telegramUserId}`; + const orderSessionData = await env.SESSION_KV.get(orderSessionKey); + + if (orderSessionData && body.message.trim() !== '신청') { + try { + const { plan } = JSON.parse(orderSessionData); + await env.SESSION_KV.delete(orderSessionKey); + + // Don't show cancellation message if it's a command + if (!body.message.startsWith('/')) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: `⏹️ 서버 신청이 취소되었습니다.\n\n신청하려던 서버: ${plan}`, + processing_time_ms: processingTimeMs, + }); + } + } catch (error) { + logger.error('Chat API - 신청 세션 취소 오류', toError(error)); + } + } + // 명령어 처리 if (body.message.startsWith('/')) { const [command, ...argParts] = body.message.split(' '); @@ -429,6 +638,12 @@ async function handleChatApi(request: Request, env: Env): Promise { // 2. AI 응답 생성 responseText = await generateAIResponse(env, userId, chatIdStr, body.message, telegramUserId); + // __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비) + if (responseText.includes('__DIRECT__')) { + const directIndex = responseText.indexOf('__DIRECT__'); + responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); + } + // 3. 봇 응답 버퍼에 추가 await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); diff --git a/src/server-agent.ts b/src/server-agent.ts index 380d50c..d6e243b 100644 --- a/src/server-agent.ts +++ b/src/server-agent.ts @@ -9,69 +9,103 @@ * - Brave Search / Context7 도구로 최신 트렌드 반영 */ -import type { Env, ServerSession, BandwidthInfo } from './types'; +import type { Env, ServerSession, BandwidthInfo, RecommendResponse } from './types'; import { createLogger } from './utils/logger'; import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; import { formatTrafficInfo } from './utils/formatters'; const logger = createLogger('server-agent'); -// KV Session Management -const SESSION_TTL = 3600; // 1 hour -const SESSION_KEY_PREFIX = 'server_session:'; +// D1 Session Management +const SESSION_TTL_MS = 3600 * 1000; // 1 hour in milliseconds export async function getServerSession( - kv: KVNamespace, + db: D1Database, userId: string ): Promise { try { - const key = `${SESSION_KEY_PREFIX}${userId}`; - logger.info('세션 조회 시도', { userId, key }); - const data = await kv.get(key, 'json'); + const now = Date.now(); + const result = await db.prepare( + 'SELECT * FROM server_sessions WHERE user_id = ? AND expires_at > ?' + ).bind(userId, now).first<{ + user_id: string; + status: string; + collected_info: string | null; + last_recommendation: string | null; + messages: string | null; + created_at: number; + updated_at: number; + }>(); - if (!data) { - logger.info('세션 없음', { userId, key }); + if (!result) { + logger.info('세션 없음', { userId }); return null; } - logger.info('세션 조회 성공', { userId, key, status: (data as ServerSession).status }); - return data as ServerSession; + const session: ServerSession = { + telegramUserId: result.user_id, + status: result.status as ServerSession['status'], + collectedInfo: result.collected_info ? JSON.parse(result.collected_info) : {}, + lastRecommendation: result.last_recommendation ? JSON.parse(result.last_recommendation) : undefined, + messages: result.messages ? JSON.parse(result.messages) : [], + createdAt: result.created_at, + updatedAt: result.updated_at, + }; + + logger.info('세션 조회 성공', { userId, status: session.status, hasLastRecommendation: !!session.lastRecommendation }); + return session; } catch (error) { - logger.error('세션 조회 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` }); + logger.error('세션 조회 실패', error as Error, { userId }); return null; } } export async function saveServerSession( - kv: KVNamespace, + db: D1Database, userId: string, session: ServerSession ): Promise { try { - const key = `${SESSION_KEY_PREFIX}${userId}`; - session.updatedAt = Date.now(); + const now = Date.now(); + const expiresAt = now + SESSION_TTL_MS; - const sessionData = JSON.stringify(session); - logger.info('세션 저장 시도', { userId, key, status: session.status, dataLength: sessionData.length }); + await db.prepare(` + INSERT INTO server_sessions + (user_id, status, collected_info, last_recommendation, messages, created_at, updated_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + status = excluded.status, + collected_info = excluded.collected_info, + last_recommendation = excluded.last_recommendation, + messages = excluded.messages, + updated_at = excluded.updated_at, + expires_at = excluded.expires_at + `).bind( + userId, + session.status, + JSON.stringify(session.collectedInfo || {}), + session.lastRecommendation ? JSON.stringify(session.lastRecommendation) : null, + JSON.stringify(session.messages || []), + session.createdAt || now, + now, + expiresAt + ).run(); - await kv.put(key, sessionData, { - expirationTtl: SESSION_TTL, - }); - - logger.info('세션 저장 성공', { userId, key, status: session.status }); + logger.info('세션 저장 성공', { userId, status: session.status }); } catch (error) { - logger.error('세션 저장 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` }); + logger.error('세션 저장 실패', error as Error, { userId }); throw error; } } export async function deleteServerSession( - kv: KVNamespace, + db: D1Database, userId: string ): Promise { try { - const key = `${SESSION_KEY_PREFIX}${userId}`; - await kv.delete(key); + await db.prepare('DELETE FROM server_sessions WHERE user_id = ?') + .bind(userId) + .run(); logger.info('세션 삭제 성공', { userId }); } catch (error) { logger.error('세션 삭제 실패', error as Error, { userId }); @@ -79,6 +113,23 @@ export async function deleteServerSession( } } +export async function cleanupExpiredSessions(db: D1Database): Promise { + try { + const result = await db.prepare( + 'DELETE FROM server_sessions WHERE expires_at < ?' + ).bind(Date.now()).run(); + + const deleted = result.meta.changes || 0; + if (deleted > 0) { + logger.info('만료 세션 정리', { deleted }); + } + return deleted; + } catch (error) { + logger.error('만료 세션 정리 실패', error as Error); + return 0; + } +} + // Server Expert AI Tools const serverExpertTools = [ { @@ -146,29 +197,130 @@ async function executeServerExpertTool( } } +/** + * 사용자 메시지에서 리전 선호도 추출 + * @param message 사용자 메시지 + * @returns 감지된 리전 코드 배열 (undefined if none) + */ +function extractRegionPreference(message: string): string[] | undefined { + const lower = message.toLowerCase(); + const regions: string[] = []; + + // 한국/서울 + if (/한국|서울|seoul|korea|kr\b/.test(lower)) { + regions.push('seoul'); + } + // 일본/도쿄 + if (/일본|도쿄|tokyo|japan|jp\b/.test(lower)) { + regions.push('tokyo'); + } + // 오사카 + if (/오사카|osaka/.test(lower)) { + regions.push('osaka'); + } + // 싱가포르 + if (/싱가포르|singapore|sg\b/.test(lower)) { + regions.push('singapore'); + } + + return regions.length > 0 ? regions : undefined; +} + +/** + * 사용자 메시지에서 기술 스택 추출 + * @param messages 사용자 메시지 (전체 대화 내용) + * @returns 감지된 tech stack 배열 + */ +function extractTechStack(messages: string): string[] { + const lower = messages.toLowerCase(); + const stack: string[] = []; + + // 데이터베이스 + if (/postgresql|postgres|postgis/.test(lower)) stack.push('postgresql'); + if (/mysql|mariadb/.test(lower)) stack.push('mysql'); + if (/mongodb|mongo/.test(lower)) stack.push('mongodb'); + + // 캐시/메시징 + if (/redis/.test(lower)) stack.push('redis'); + if (/memcached/.test(lower)) stack.push('memcached'); + if (/kafka|rabbitmq/.test(lower)) stack.push('messaging'); + + // 런타임 + if (/node\.?js|nodejs|express/.test(lower)) stack.push('nodejs'); + if (/python|django|flask|fastapi/.test(lower)) stack.push('python'); + if (/java|spring/.test(lower)) stack.push('java'); + if (/golang|go\s/.test(lower)) stack.push('go'); + + // 플랫폼 + if (/wordpress/.test(lower)) stack.push('wordpress'); + if (/laravel|php/.test(lower)) stack.push('php'); + + // 서비스 유형 + if (/saas|b2b|enterprise/.test(lower)) stack.push('saas'); + if (/ecommerce|쇼핑몰|이커머스/.test(lower)) stack.push('ecommerce'); + if (/게임|game|minecraft|팰월드|palworld/.test(lower)) stack.push('game'); + if (/streaming|스트리밍|video/.test(lower)) stack.push('streaming'); + + return stack; +} + // Tech stack inference from use case function inferTechStack(useCase: string): string[] { - const useCaseLower = useCase.toLowerCase(); + const lower = useCase.toLowerCase(); - if (/블로그|blog|wordpress/.test(useCaseLower)) { - return ['wordpress']; + // 고성능 데이터베이스 감지 + if (/postgresql|postgres|postgis/.test(lower)) { + return ['postgresql', 'nodejs']; } - if (/쇼핑몰|이커머스|ecommerce|shop|store/.test(useCaseLower)) { - return ['ecommerce']; + if (/redis|memcached|cache/.test(lower)) { + return ['redis', 'nodejs']; } - if (/커뮤니티|게시판|forum|community/.test(useCaseLower)) { - return ['php', 'mysql']; + if (/mongodb|mongo/.test(lower)) { + return ['mongodb', 'nodejs']; } - if (/api|백엔드|backend/.test(useCaseLower)) { - return ['nodejs', 'express']; + + // SaaS / B2B 감지 - 일반적으로 고성능 필요 + if (/saas|b2b|enterprise|엔터프라이즈/.test(lower)) { + return ['nodejs', 'postgresql', 'redis']; } + // 실시간 서비스 + if (/realtime|real-time|실시간|websocket|socket\.io/.test(lower)) { + return ['nodejs', 'redis']; + } + + // 기존 규칙들... + if (/블로그|blog|wordpress/.test(lower)) return ['wordpress']; + if (/쇼핑몰|이커머스|ecommerce|shop|store/.test(lower)) return ['ecommerce']; + if (/커뮤니티|게시판|forum|community/.test(lower)) return ['php', 'mysql']; + if (/api|백엔드|backend/.test(lower)) return ['nodejs', 'express']; + if (/게임|game|minecraft|마인크래프트|팰월드|palworld/.test(lower)) return ['game']; + return ['web']; // Default } // Expected users inference from scale // Returns concurrent users (not DAU) -function inferExpectedUsers(scale: string): number { +function inferExpectedUsers(scale: string, techStack?: string[]): number { + // 고성능 기술 스택이면 기본 사용자 수 증가 + const isHighPerf = techStack?.some(t => + ['postgresql', 'redis', 'mongodb', 'elasticsearch', 'kafka'].includes(t.toLowerCase()) + ); + + // SaaS/Enterprise면 더 높은 기본값 + const isSaaS = techStack?.some(t => + ['saas', 'enterprise', 'b2b'].includes(t.toLowerCase()) + ) || scale === 'saas' || scale === 'enterprise'; + + if (isSaaS) { + return scale === 'business' ? 500 : 200; + } + + if (isHighPerf) { + return scale === 'business' ? 300 : 100; + } + + // 기존 기본값 // DAU → 동시접속자 변환 (5-10% 비율 적용) if (scale === 'personal') return 10; // DAU 100명 → 동접 10명 if (scale === 'business') return 50; // DAU 500명 → 동접 50명 @@ -198,28 +350,6 @@ interface OpenAIAPIResponse { }>; } -// RecommendResponse 타입 (server-tool.ts와 동일) -interface RecommendResponse { - recommendations: Array<{ - server: { - instance_name: string; - vcpu: number; - memory_gb: number; - storage_gb: number; - transfer_tb: number; - monthly_price: number; - provider_name: string; - region_code: string; - region_name: string; - }; - score: number; - estimated_capacity?: { - max_concurrent_users?: number; - }; - bandwidth_info?: BandwidthInfo; - }>; -} - // OpenAI 호출 (서버 전문가 AI with Function Calling) async function callServerExpertAI( env: Env, @@ -339,7 +469,11 @@ ${session.collectedInfo.budgetLimit ? `- 예산: ${session.collectedInfo.budgetL - 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명) - 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB - 게임서버 → 고사양 CPU, 낮은 레이턴시 리전 -- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명) +- SaaS/B2B/Enterprise → 최소 4GB+ RAM, PostgreSQL+Redis 권장, 500명+ 동시접속 가정 +- API 서버 → 트래픽에 따라 2~8GB, Redis 캐시 권장 +- 실시간 서비스 (WebSocket) → 최소 4GB RAM, Redis 권장 +- 고성능 DB (PostgreSQL, MongoDB) → 최소 4GB+ RAM, 높은 IOPS +- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명), SaaS→DAU 2000명 (동접 200명) ## 현재 수집된 정보 ${JSON.stringify(session.collectedInfo, null, 2)} @@ -385,6 +519,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)} messages, tools: serverExpertTools, tool_choice: 'auto', + response_format: { type: 'json_object' }, max_tokens: 800, temperature: 0.7, }; @@ -419,20 +554,22 @@ ${JSON.stringify(session.collectedInfo, null, 2)} tool_calls: assistantMessage.tool_calls, }); - // Execute each tool and add results - for (const toolCall of assistantMessage.tool_calls) { - const args = JSON.parse(toolCall.function.arguments); - const result = await executeServerExpertTool(toolCall.function.name, args, env); + // Execute tools in parallel for better performance + const toolResults = await Promise.all( + assistantMessage.tool_calls.map(async (toolCall) => { + const args = JSON.parse(toolCall.function.arguments); + const result = await executeServerExpertTool(toolCall.function.name, args, env); + return { + role: 'tool' as const, + tool_call_id: toolCall.id, + name: toolCall.function.name, + content: result, + }; + }) + ); - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - name: toolCall.function.name, - content: result, - }); - - toolCallCount++; - } + messages.push(...toolResults); + toolCallCount += toolResults.length; // Continue loop to get AI's response with tool results continue; @@ -458,10 +595,30 @@ ${JSON.stringify(session.collectedInfo, null, 2)} throw new Error('Invalid AI response structure'); } + // AI 응답에서 리전 정보가 없으면 사용자 메시지에서 추출 시도 + const finalCollectedInfo = parsed.collectedInfo || session.collectedInfo; + + if (!finalCollectedInfo.regionPreference) { + // 전체 대화 히스토리에서 리전 감지 + const allMessages = [ + ...session.messages.map(m => m.content), + userMessage, + ].join(' '); + + const detectedRegions = extractRegionPreference(allMessages); + if (detectedRegions) { + finalCollectedInfo.regionPreference = detectedRegions; + logger.info('사용자 메시지에서 리전 자동 감지', { + regions: detectedRegions, + userId: session.telegramUserId + }); + } + } + return { action: parsed.action, message: parsed.message, - collectedInfo: parsed.collectedInfo || session.collectedInfo, + collectedInfo: finalCollectedInfo, }; } @@ -482,7 +639,8 @@ ${JSON.stringify(session.collectedInfo, null, 2)} export async function processServerConsultation( userMessage: string, session: ServerSession, - env: Env + env: Env, + sendIntermediateMessage?: (message: string) => Promise ): Promise { try { logger.info('상담 처리 시작', { @@ -491,11 +649,22 @@ export async function processServerConsultation( status: session.status }); + // ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리 + if (session.status === 'ordering') { + // "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임 + const orderConfirmKey = `server_order_confirm:${session.telegramUserId}`; + await env.SESSION_KV?.delete(orderConfirmKey); + await deleteServerSession(env.DB, session.telegramUserId); + + logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.telegramUserId }); + return '__PASSTHROUGH__'; // 일반 대화로 전환 + } + // 취소 키워드 처리 (모든 상태에서 작동) // "취소", "다시", "처음", "리셋", "초기화" 등 if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) || /취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) { - await deleteServerSession(env.SESSION_KV, session.telegramUserId); + await deleteServerSession(env.DB, session.telegramUserId); logger.info('사용자 요청으로 상담 취소', { userId: session.telegramUserId, previousStatus: session.status, @@ -506,7 +675,7 @@ export async function processServerConsultation( // "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋) if (/서버\s*추천/.test(userMessage)) { - await deleteServerSession(env.SESSION_KV, session.telegramUserId); + await deleteServerSession(env.DB, session.telegramUserId); logger.info('서버 추천 키워드로 세션 리셋', { userId: session.telegramUserId, previousStatus: session.status @@ -520,17 +689,25 @@ export async function processServerConsultation( createdAt: Date.now(), updatedAt: Date.now() }; - await saveServerSession(env.SESSION_KV, session.telegramUserId, newSession); + await saveServerSession(env.DB, session.telegramUserId, newSession); return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n예: 블로그, 쇼핑몰, 커뮤니티, API 서버 등'; } // 선택 단계 처리 + logger.info('[SESSION DEBUG] 선택 단계 체크', { + userId: session.telegramUserId, + status: session.status, + hasLastRecommendation: !!session.lastRecommendation, + recommendationCount: session.lastRecommendation?.recommendations?.length || 0, + willProcessSelection: session.status === 'selecting' && !!session.lastRecommendation + }); + if (session.status === 'selecting' && session.lastRecommendation) { // 상담과 무관한 키워드 감지 (selecting 상태에서만) // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; if (unrelatedPatterns.test(userMessage)) { - await deleteServerSession(env.SESSION_KV, session.telegramUserId); + await deleteServerSession(env.DB, session.telegramUserId); logger.info('무관한 요청으로 세션 자동 종료', { userId: session.telegramUserId, message: userMessage.slice(0, 30) @@ -561,15 +738,21 @@ export async function processServerConsultation( // Mark session as ordering session.status = 'ordering'; - await saveServerSession(env.SESSION_KV, session.telegramUserId, session); + await saveServerSession(env.DB, session.telegramUserId, session); - // 주문 확인 메시지 생성 (인라인 버튼 포함) - const keyboardData = JSON.stringify({ - type: 'server_order', + // 주문 확인 세션 저장 (텍스트 기반 확인) + const orderConfirmKey = `server_order_confirm:${session.telegramUserId}`; + const orderConfirmData = JSON.stringify({ userId: session.telegramUserId, index: selectedIndex, - plan: selected.plan_name + plan: selected.plan_name, + pricingId: selected.pricing_id, + region: selected.region.code, + label: `${selected.plan_name.toLowerCase().replace(/\s+/g, '-')}-server`, }); + logger.info('주문 확인 세션 저장', { orderConfirmKey, userId: session.telegramUserId }); + await env.SESSION_KV.put(orderConfirmKey, orderConfirmData, { expirationTtl: 300 }); + logger.info('주문 확인 세션 저장 완료', { orderConfirmKey }); // 트래픽 정보 포맷팅 let trafficInfo = ''; @@ -586,18 +769,21 @@ export async function processServerConsultation( gross_monthly_tb: selected.price.gross_monthly_tb, cdn_cache_hit_rate: selected.price.cdn_cache_hit_rate, }; - trafficInfo = `• ${formatTrafficInfo(bandwidthInfo, 'KRW')}\n`; + trafficInfo = `• ${formatTrafficInfo(bandwidthInfo)}\n`; } + // 가격 표시 (항상 KRW로 표시) + const priceDisplay = `₩${selected.price.monthly_krw.toLocaleString()}`; + return `🖥️ ${selected.plan_name} 신청 확인\n\n` + `• 제공사: ${selected.provider}\n` + `• 스펙: ${selected.specs.vcpu}vCPU / ${selected.specs.ram_gb}GB RAM / ${selected.specs.storage_gb}GB SSD\n` + `• 리전: ${selected.region.name} (${selected.region.code})\n` + - `• 가격: ₩${selected.price.monthly_krw.toLocaleString()}/월\n` + + `• 가격: ${priceDisplay}/월\n` + `• 대역폭: ${selected.price.bandwidth_tb}TB 포함\n` + trafficInfo + - `\n신청하시겠습니까?\n\n` + - `__KEYBOARD__${keyboardData}__END__`; + `\n⚠️ 정말 신청하시려면 '신청'이라고 입력하세요.\n` + + `(5분 내 응답 없으면 자동 취소됩니다)`; } else { return `번호를 다시 확인해주세요. 1번부터 ${session.lastRecommendation.recommendations.length}번 중에서 선택해주세요.`; } @@ -620,19 +806,44 @@ export async function processServerConsultation( session.messages.push({ role: 'assistant', content: aiResult.message }); if (aiResult.action === 'recommend') { + // Send intermediate message to user + if (sendIntermediateMessage) { + await sendIntermediateMessage('🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.'); + } + // Mark session as recommending session.status = 'recommending'; - await saveServerSession(env.SESSION_KV, session.telegramUserId, session); + await saveServerSession(env.DB, session.telegramUserId, session); // 1. Call recommendation API (추천 먼저 받기) logger.info('추천 API 호출', { collectedInfo: session.collectedInfo }); const { executeServerAction, getRecommendationData } = await import('./tools/server-tool'); - const techStack = session.collectedInfo.useCase + // 전체 메시지 내용 (tech stack 추출 및 리전 추출에 재사용) + const allMessages = session.messages.map(m => m.content).join(' '); + + // Tech Stack: useCase에서 추론 + 전체 메시지에서 추출한 것 병합 + let techStack = session.collectedInfo.useCase ? inferTechStack(session.collectedInfo.useCase) : ['web']; + // 전체 메시지에서 추가 tech stack 추출 + const extractedTech = extractTechStack(allMessages); + if (extractedTech.length > 0) { + // 추출된 tech를 기존 stack에 병합 (중복 제거) + techStack = [...new Set([...techStack, ...extractedTech])]; + // 'web' 제거 (더 구체적인 stack이 있으면) + if (techStack.length > 1 && techStack.includes('web')) { + techStack = techStack.filter(t => t !== 'web'); + } + logger.info('메시지에서 tech stack 추출', { + extracted: extractedTech, + merged: techStack, + userId: session.telegramUserId + }); + } + // 동시접속자 우선 사용, 없으면 scale 기반 추론 let expectedUsers = 10; // Default const concurrent = Number(session.collectedInfo.expectedConcurrent) || 0; @@ -644,7 +855,19 @@ export async function processServerConsultation( // DAU가 있으면 10% 비율로 동시접속자 계산 expectedUsers = Math.ceil(dau * 0.1); } else if (session.collectedInfo.scale) { - expectedUsers = inferExpectedUsers(session.collectedInfo.scale); + expectedUsers = inferExpectedUsers(session.collectedInfo.scale, techStack); + } + + // 리전 선호도 최종 확인 (세션에 없으면 메시지에서 재추출) + let finalRegionPreference = session.collectedInfo.regionPreference; + if (!finalRegionPreference) { + finalRegionPreference = extractRegionPreference(allMessages); + if (finalRegionPreference) { + logger.info('추천 직전 리전 재감지', { + regions: finalRegionPreference, + userId: session.telegramUserId + }); + } } const recommendationData = await getRecommendationData( @@ -652,7 +875,7 @@ export async function processServerConsultation( tech_stack: techStack, expected_users: expectedUsers, use_case: session.collectedInfo.useCase || '웹 서비스', - region_preference: session.collectedInfo.regionPreference, + region_preference: finalRegionPreference, budget_limit: session.collectedInfo.budgetLimit, lang: 'ko', }, @@ -663,6 +886,7 @@ export async function processServerConsultation( if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) { session.lastRecommendation = { recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ + pricing_id: rec.server.id, // cloud-instances-db.anvil_pricing.id plan_name: rec.server.instance_name, provider: rec.server.provider_name, specs: { @@ -681,7 +905,8 @@ export async function processServerConsultation( gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb, cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate, overage_tb: rec.bandwidth_info?.estimated_overage_tb, - overage_cost_krw: rec.bandwidth_info?.estimated_overage_cost + overage_cost_krw: rec.bandwidth_info?.estimated_overage_cost, + currency: rec.server.currency, }, score: rec.score, max_users: rec.estimated_capacity?.max_concurrent_users || 0 @@ -710,21 +935,22 @@ export async function processServerConsultation( // Mark session as selecting (사용자 선택 대기) session.status = 'selecting'; - await saveServerSession(env.SESSION_KV, session.telegramUserId, session); + await saveServerSession(env.DB, session.telegramUserId, session); - // 4. AI 검토 코멘트 + 추천 결과 함께 반환 - return `${reviewResult.message}\n\n${formattedRecommendation}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; + // 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에) + // __DIRECT__ 마커가 앞에 와야 제대로 처리됨 + return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; } else { // 추천 결과 없음 - 세션 삭제 session.status = 'completed'; - await deleteServerSession(env.SESSION_KV, session.telegramUserId); + await deleteServerSession(env.DB, session.telegramUserId); return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; } } else { // Continue gathering information session.status = 'gathering'; - await saveServerSession(env.SESSION_KV, session.telegramUserId, session); + await saveServerSession(env.DB, session.telegramUserId, session); return aiResult.message; } @@ -732,7 +958,7 @@ export async function processServerConsultation( logger.error('상담 처리 실패', error as Error, { userId: session.telegramUserId }); // Clean up session on error - await deleteServerSession(env.SESSION_KV, session.telegramUserId); + await deleteServerSession(env.DB, session.telegramUserId); return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.'; } diff --git a/src/services/conversation-service.ts b/src/services/conversation-service.ts index d4d1e75..f1e61e5 100644 --- a/src/services/conversation-service.ts +++ b/src/services/conversation-service.ts @@ -5,6 +5,9 @@ import { generateAIResponse, } from '../summary-service'; import { sendChatAction } from '../telegram'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('conversation'); export interface ConversationResult { responseText: string; @@ -26,7 +29,9 @@ export class ConversationService { telegramUserId: string ): Promise { // 1. 타이핑 액션 전송 (비동기로 실행, 기다리지 않음) - sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(console.error); + sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(err => + logger.error('타이핑 액션 전송 실패', err as Error) + ); // 2. 사용자 메시지 버퍼에 추가 await addToBuffer(this.env.DB, userId, chatId, 'user', text); @@ -40,6 +45,12 @@ export class ConversationService { telegramUserId ); + // __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비) + if (responseText.includes('__DIRECT__')) { + const directIndex = responseText.indexOf('__DIRECT__'); + responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); + } + // 4. 봇 응답 버퍼에 추가 (키보드 데이터 마커 등은 그대로 저장) // 실제 사용자에게 보여질 텍스트만 저장하는 것이 좋으나, // 현재 구조상 전체를 저장하고 나중에 컨텍스트로 활용 시 정제될 수 있음 @@ -58,18 +69,18 @@ export class ConversationService { const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s); if (keyboardMatch) { - console.log('[ConversationService] Keyboard marker detected:', keyboardMatch[1].substring(0, 100)); + logger.debug('키보드 마커 감지', { preview: keyboardMatch[1].substring(0, 100) }); responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/s, ''); try { keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData; - console.log('[ConversationService] Keyboard parsed successfully:', keyboardData.type); + logger.debug('키보드 파싱 성공', { type: keyboardData.type }); } catch (e) { - console.error('[ConversationService] Keyboard parsing error:', e); - console.error('[ConversationService] Failed to parse:', keyboardMatch[1]); + logger.error('키보드 파싱 오류', e as Error, { rawData: keyboardMatch[1] }); } } else if (responseText.includes('__KEYBOARD__')) { - console.warn('[ConversationService] Keyboard marker found but regex did not match'); - console.warn('[ConversationService] Response preview:', responseText.substring(0, 200)); + logger.warn('키보드 마커 발견했으나 정규식 매칭 실패', { + preview: responseText.substring(0, 200) + }); } return { diff --git a/src/tools/server-tool.ts b/src/tools/server-tool.ts index b297a43..81eac22 100644 --- a/src/tools/server-tool.ts +++ b/src/tools/server-tool.ts @@ -1,10 +1,17 @@ -import type { Env, BandwidthInfo } from '../types'; +import type { + Env, + ProvisionResponse, + ProvisionOrder, + OSImage, + RecommendResponse +} from '../types'; import { retryWithBackoff, RetryError } from '../utils/retry'; import { createLogger, maskUserId } from '../utils/logger'; import { ERROR_MESSAGES } from '../constants/messages'; import { formatTrafficInfo } from '../utils/formatters'; const logger = createLogger('server-tool'); +const provisionLogger = createLogger('provision'); // CDN 캐시 히트율 상수 const CDN_CACHE_HIT_RATES = { @@ -55,97 +62,19 @@ function isErrorResult(result: unknown): result is { error: string } { return typeof result === 'object' && result !== null && 'error' in result; } -// Cloud Orchestrator API 응답 타입 -interface ServerSpec { - id: number; - provider_name: string; - instance_id: string; - instance_name: string; - vcpu: number; - memory_mb: number; - memory_gb: number; - storage_gb: number; - network_speed_gbps: number | null; - instance_family: string; - gpu_count: number; - gpu_type: string | null; - monthly_price: number; - region_name: string; - region_code: string; - country_code: string; - transfer_tb: number; - transfer_price_per_gb: number; - currency: string; -} - -interface BenchmarkItem { - name: string; - category: string; - score: number; - percentile: number; -} - -interface AvailableRegion { - region_name: string; - region_code: string; - monthly_price: number; -} - -interface ServerRecommendation { - server: ServerSpec; - score: number; - analysis: { - tech_fit: string; - capacity: string; - cost_efficiency: string; - scalability: string; - }; - estimated_capacity: { - max_concurrent_users: number; - requests_per_second: number; - }; - bandwidth_info?: BandwidthInfo; - benchmark_reference?: { - processor_name: string; - benchmarks: BenchmarkItem[]; - }; - vps_benchmark_reference?: { - plan_name: string; - geekbench_single: number; - geekbench_multi: number; - monthly_price_usd: number; - performance_per_dollar: number; - }; - available_regions?: AvailableRegion[]; -} - -interface RecommendResponse { - recommendations: ServerRecommendation[]; - infrastructure_tips?: string[]; - bandwidth_estimate?: { - monthly_tb: number; - monthly_gb: number; - daily_gb: number; - category: string; - description: string; - }; - total_candidates?: number; - cached?: boolean; -} - export const manageServerTool = { type: 'function', function: { name: 'manage_server', - description: '클라우드 서버 관리 및 추천. 서버/VPS/클라우드/호스팅 관련 요청 시 사용. 상담 시작: start_consultation', + description: '클라우드 서버 관리 및 추천. 서버/VPS/클라우드/호스팅 관련 요청 시 반드시 사용. 내 서버 목록: action="list", 서버 추천(용도/규모 알면): action="recommend", 서버 추천(정보 부족): action="start_consultation"', parameters: { type: 'object', properties: { action: { type: 'string', - enum: ['recommend', 'order', 'start', 'stop', 'delete', 'list', + enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'start_consultation', 'continue_consultation', 'cancel_consultation'], - description: 'start_consultation: 서버 추천 상담 시작, continue_consultation: 상담 계속, recommend: 직접 추천', + description: 'recommend: 서버 추천 (용도/규모 파악됨), start_consultation: 상담 시작 (정보 부족), list: 내 서버 목록, info: 서버 상세, images: OS 목록', }, tech_stack: { type: 'array', @@ -181,11 +110,11 @@ export const manageServerTool = { }, server_id: { type: 'string', - description: '서버 ID. order/start/stop/delete action에서 필수', + description: '서버 ID (레거시, 사용 안 함)', }, region_code: { type: 'string', - description: '리전 코드 (예: "tokyo3"). order action에서 필수', + description: '리전 코드 (레거시, 사용 안 함)', }, label: { type: 'string', @@ -195,6 +124,18 @@ export const manageServerTool = { type: 'string', description: '사용자 메시지. continue_consultation action에서 필수', }, + pricing_id: { + type: 'number', + description: 'Pricing ID (서버 스펙 ID). order action에서 필수', + }, + order_id: { + type: 'number', + description: '주문 번호. info, delete action에서 필수', + }, + image: { + type: 'string', + description: 'OS 이미지 키 (예: "ubuntu_22_04"). order action에서 선택 (기본값 사용 가능)', + }, }, required: ['action'], }, @@ -260,6 +201,91 @@ async function callCloudOrchestratorApi( } } +// 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 + let fullEndpoint = endpoint; + if ((method === 'GET' || method === 'DELETE') && userId && !endpoint.includes('?')) { + fullEndpoint = `${endpoint}?user_id=${userId}`; + } else if ((method === 'GET' || method === 'DELETE') && userId) { + fullEndpoint = `${endpoint}&user_id=${userId}`; + } + + // Service Binding 우선, fallback: URL + 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 요청 사용', { 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, + }); + return { + success: false, + error: `프로비저닝 API 호출 실패: HTTP ${response.status}`, + }; + } + + 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 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + }; + } +} + // 추천 데이터 조회 (포맷팅 없이 원본 반환) export async function getRecommendationData( args: { @@ -320,10 +346,8 @@ function formatRecommendations(data: RecommendResponse): string { recommendations.slice(0, 3).forEach((rec, index) => { const server = rec.server; - // 가격 포맷팅 - const price = server.currency === 'KRW' - ? `₩${Math.round(server.monthly_price).toLocaleString()}` - : `$${server.monthly_price}`; + // 가격 포맷팅 (항상 KRW로 표시) + const price = `₩${Math.round(server.monthly_price).toLocaleString()}`; // 대역폭 포맷팅 const bandwidth = server.transfer_tb ? `${server.transfer_tb}TB` : '무제한'; @@ -335,14 +359,12 @@ function formatRecommendations(data: RecommendResponse): string { // 대역폭 정보 (항상 표시) if (rec.bandwidth_info) { - const trafficInfo = formatTrafficInfo(rec.bandwidth_info, server.currency); + const trafficInfo = formatTrafficInfo(rec.bandwidth_info); response += ` • ${trafficInfo}\n`; - // 총 예상 비용 (초과 있을 때만 표시) + // 총 예상 비용 (초과 있을 때만 표시, 항상 KRW로 표시) if (rec.bandwidth_info.estimated_overage_tb > 0 && rec.bandwidth_info.estimated_overage_cost > 0) { - const totalCost = server.currency === 'KRW' - ? `₩${Math.round(rec.bandwidth_info.total_estimated_cost).toLocaleString()}` - : `$${rec.bandwidth_info.total_estimated_cost.toFixed(2)}`; + const totalCost = `₩${Math.round(rec.bandwidth_info.total_estimated_cost).toLocaleString()}`; response += ` • 총 예상 비용: ${totalCost}/월\n`; } } @@ -356,9 +378,195 @@ function formatRecommendations(data: RecommendResponse): string { response += '\n'; }); + // 환불 정책 안내 추가 + response += '\n💡 환불 정책: 720시간 = 1개월 기준\n'; + response += ' 해지 시 미사용 시간은 시간당 요금으로 환불\n\n'; + + // 선택 가이드 추가 + response += '💡 원하는 서버를 선택하려면 번호를 입력하세요 (예: 1번)'; + return response.trim(); } +// 서버 상태 이모지 +function getStatusEmoji(status: string): string { + switch (status) { + case 'active': + return '🟢'; + case 'provisioning': + return '🟡'; + case 'stopped': + return '🔴'; + case 'deleted': + return '⚫'; + case 'failed': + return '❌'; + default: + return '⚪'; + } +} + +// 서버 상태 텍스트 +function getStatusText(status: string): string { + switch (status) { + case 'active': + return '가동 중'; + case 'provisioning': + return '생성 중'; + case 'stopped': + return '중지됨'; + case 'deleted': + 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 { + // 활성 상태만 표시 (terminated 제외) + const activeOrders = orders?.filter(order => + ['pending', 'provisioning', 'active'].includes(order.status) + ) || []; + + if (activeOrders.length === 0) { + return '🖥️ 등록된 서버가 없습니다.\n\n서버를 추천받으려면 "서버 추천"이라고 말씀해주세요.'; + } + + let response = '__DIRECT__\n🖥️ 내 서버 목록\n\n'; + + activeOrders.forEach((order, index) => { + const emoji = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'][index] || '▪️'; + const statusEmoji = getStatusEmoji(order.status); + const statusText = getStatusText(order.status); + + response += `${emoji} ${order.label} (#${order.id})\n`; + if (order.ip_address) { + response += ` • IP: ${order.ip_address}\n`; + } + response += ` • 상태: ${statusEmoji} ${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'; + }); + + 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, @@ -374,6 +582,9 @@ export async function executeServerAction( region_code?: string; label?: string; message?: string; + pricing_id?: number; + order_id?: number; + image?: string; }, env?: Env, telegramUserId?: string @@ -393,7 +604,7 @@ export async function executeServerAction( return '🚫 사용자 인증이 필요합니다.'; } - if (!env?.SESSION_KV) { + if (!env?.DB) { return '🚫 세션 저장소가 설정되지 않았습니다.'; } @@ -406,7 +617,7 @@ export async function executeServerAction( updatedAt: Date.now(), }; - await saveServerSession(env.SESSION_KV, telegramUserId, session); + await saveServerSession(env.DB, telegramUserId, session); logger.info('상담 세션 생성', { userId: maskUserId(telegramUserId) }); @@ -420,7 +631,7 @@ export async function executeServerAction( return '🚫 사용자 인증이 필요합니다.'; } - if (!env?.SESSION_KV) { + if (!env?.DB) { return '🚫 세션 저장소가 설정되지 않았습니다.'; } @@ -428,7 +639,7 @@ export async function executeServerAction( return '🚫 메시지가 필요합니다.'; } - const session = await getServerSession(env.SESSION_KV, telegramUserId); + const session = await getServerSession(env.DB, telegramUserId); if (!session) { return '세션이 만료되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.'; } @@ -444,11 +655,11 @@ export async function executeServerAction( return '🚫 사용자 인증이 필요합니다.'; } - if (!env?.SESSION_KV) { + if (!env?.DB) { return '🚫 세션 저장소가 설정되지 않았습니다.'; } - await deleteServerSession(env.SESSION_KV, telegramUserId); + await deleteServerSession(env.DB, telegramUserId); logger.info('상담 세션 취소', { userId: maskUserId(telegramUserId) }); @@ -489,18 +700,146 @@ export async function executeServerAction( return `🚫 ${result.error}`; } + const recommendationData = result as RecommendResponse; + + // 세션에 추천 결과 저장 (선택 기능 활성화) + if (telegramUserId && env?.DB && recommendationData.recommendations && recommendationData.recommendations.length > 0) { + try { + const { getServerSession, saveServerSession } = await import('../server-agent'); + + // 기존 세션 조회 또는 새로 생성 + let session = await getServerSession(env.DB, telegramUserId); + + if (!session) { + // 세션이 없으면 새로 생성 + session = { + telegramUserId, + status: 'selecting', + collectedInfo: { + useCase: use_case, + scale: expected_users <= 50 ? 'personal' : 'business', + expectedConcurrent: expected_users, + }, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + logger.info('새 세션 생성 (추천 결과 저장용)', { userId: telegramUserId }); + } + + // lastRecommendation 저장 + session.lastRecommendation = { + recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ + pricing_id: rec.server.id, + plan_name: rec.server.instance_name, + provider: rec.server.provider_name, + specs: { + vcpu: rec.server.vcpu, + ram_gb: rec.server.memory_gb, + storage_gb: rec.server.storage_gb + }, + region: { + code: rec.server.region_code, + name: rec.server.region_name + }, + price: { + monthly_krw: Math.round(rec.server.monthly_price), + bandwidth_tb: rec.server.transfer_tb, + estimated_monthly_tb: rec.bandwidth_info?.estimated_monthly_tb, + gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb, + cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate, + overage_tb: rec.bandwidth_info?.estimated_overage_tb, + overage_cost_krw: rec.bandwidth_info?.estimated_overage_cost, + currency: rec.server.currency, + }, + score: rec.score, + max_users: rec.estimated_capacity?.max_concurrent_users || 0 + })), + createdAt: Date.now() + }; + + // status를 'selecting'으로 변경 + session.status = 'selecting'; + session.updatedAt = Date.now(); + + await saveServerSession(env.DB, telegramUserId, session); + logger.info('추천 결과 세션 저장 완료', { + userId: telegramUserId, + recommendationCount: session.lastRecommendation.recommendations.length, + status: session.status + }); + } catch (sessionError) { + logger.error('세션 저장 실패 (무시)', sessionError as Error, { userId: telegramUserId }); + // 세션 저장 실패해도 추천 결과는 반환 + } + } + // 결과 포맷팅 - return formatRecommendations(result as RecommendResponse); + return formatRecommendations(recommendationData); } case 'order': { - const { server_id, region_code, label } = args; + const { pricing_id, label, image } = args; - if (!server_id || !region_code || !label) { - return '🚫 서버 주문에는 server_id, region_code, label이 필요합니다.'; + if (!telegramUserId) { + return '🚫 사용자 인증이 필요합니다.'; } - return '🚧 서버 주문 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.\n추후 업데이트를 통해 주문 기능이 추가될 예정입니다.'; + if (!pricing_id || !label) { + return '🚫 서버 주문에는 pricing_id와 label이 필요합니다.'; + } + + if (!env) { + return '🚫 환경 설정이 필요합니다.'; + } + + // 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 + + const orderBody: Record = { + user_id: telegramUserId, + pricing_id, + label, + dry_run: false, + }; + + 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 + if (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 response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`; + + return response; } case 'start': { @@ -523,18 +862,151 @@ export async function executeServerAction( return '🚧 서버 중지 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.'; } - case 'delete': { - const { server_id } = args; - - if (!server_id) { - return '🚫 서버 해지에는 server_id가 필요합니다.'; + case 'list': { + if (!telegramUserId) { + return '🚫 사용자 인증이 필요합니다.'; } - return '🚧 서버 해지 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.'; + 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 'list': { - return '🚧 서버 목록 조회 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.'; + 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); } default: @@ -542,6 +1014,217 @@ export async function executeServerAction( } } +// 서버 삭제 시 환불액 계산 (시간당 요금제) +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) { + // 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(); + + refundMessage = `\n\n💰 환불 정보\n• 결제 금액: ${pricePaid.toLocaleString()}원\n• 사용 시간: ${usedHours}시간\n• 환불 금액: ${refundAmount.toLocaleString()}원`; + + provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours }); + } + } 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 { deleteServerSession } = await import('../server-agent'); + await deleteServerSession(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, + }); + + // Call provision API + const result = await callProvisionAPI( + '/api/provision', + 'POST', + env, + { + user_id: telegramUserId, + pricing_id: orderData.pricingId, + label: orderData.label, + }, + telegramUserId + ); + + if (result.error) { + provisionLogger.error('서버 주문 실패', new Error(result.error), { orderData }); + + // Check for specific error types + if (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 { deleteServerSession } = await import('../server-agent'); + await deleteServerSession(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; @@ -556,6 +1239,9 @@ export async function executeManageServer( region_code?: string; label?: string; message?: string; + pricing_id?: number; + order_id?: number; + image?: string; }, env?: Env, telegramUserId?: string diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index e38fa48..d369d41 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -36,7 +36,7 @@ export function formatTB(value: number): string { * - CDN 없음 + 초과: "예상 트래픽: 1.50TB → 초과 0.50TB (₩10,000)" * - CDN 없음 + 포함: "예상 트래픽: 0.80TB (포함 범위 내)" */ -export function formatTrafficInfo(bandwidth_info: BandwidthInfo, currency: string): string { +export function formatTrafficInfo(bandwidth_info: BandwidthInfo): string { // CDN 정보가 있는 경우 if (bandwidth_info.gross_monthly_tb !== undefined && bandwidth_info.cdn_cache_hit_rate !== undefined) { // CDN 캐시 히트율 범위 검증 (0-100%) @@ -47,9 +47,7 @@ export function formatTrafficInfo(bandwidth_info: BandwidthInfo, currency: strin // 초과 여부 판단 (통일된 조건) if (bandwidth_info.estimated_overage_tb > 0 && bandwidth_info.estimated_overage_cost > 0) { const overageTB = formatTB(bandwidth_info.estimated_overage_tb); - const overageCost = currency === 'KRW' - ? `₩${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}` - : `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`; + const overageCost = `₩${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}`; return `예상 트래픽: ${grossTB} (CDN ${hitRate}% → 원본 ${estimatedTB}) → 초과 ${overageTB} (${overageCost})`; } else { return `예상 트래픽: ${grossTB} (CDN ${hitRate}% → 원본 ${estimatedTB})`; @@ -61,9 +59,7 @@ export function formatTrafficInfo(bandwidth_info: BandwidthInfo, currency: strin // 초과 여부 판단 (통일된 조건) if (bandwidth_info.estimated_overage_tb > 0 && bandwidth_info.estimated_overage_cost > 0) { const overageTB = formatTB(bandwidth_info.estimated_overage_tb); - const overageCost = currency === 'KRW' - ? `₩${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}` - : `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`; + const overageCost = `₩${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}`; return `예상 트래픽: ${estimatedTB} → 초과 ${overageTB} (${overageCost})`; } else { return `예상 트래픽: ${estimatedTB} (포함 범위 내)`;