fix: server recommendation issues and __DIRECT__ tag visibility

- 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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-28 20:24:54 +09:00
parent 53547f097e
commit d3b743c3c1
5 changed files with 1363 additions and 229 deletions

View File

@@ -284,8 +284,8 @@ async function handleDepositDeduct(request: Request, env: Env): Promise<Response
* @returns JSON response with AI response * @returns JSON response with AI response
*/ */
async function handleTestApi(request: Request, env: Env): Promise<Response> { async function handleTestApi(request: Request, env: Env): Promise<Response> {
// 프로덕션 환경에서는 비활성화 // 개발/테스트 환경에서만 활성화 (명시적 설정 필수)
if (env.ENVIRONMENT === 'production') { if (env.ENVIRONMENT !== 'development' && env.ENVIRONMENT !== 'test') {
return new Response('Not Found', { status: 404 }); return new Response('Not Found', { status: 404 });
} }
@@ -310,8 +310,8 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
const body = parseResult.data; const body = parseResult.data;
// 간단한 인증 // 인증 (Timing-safe comparison 사용)
if (body.secret !== env.WEBHOOK_SECRET) { if (!timingSafeEqual(body.secret || '', env.WEBHOOK_SECRET || '')) {
return Response.json({ error: 'Unauthorized' }, { status: 401 }); return Response.json({ error: 'Unauthorized' }, { status: 401 });
} }
@@ -339,6 +339,12 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
// 2. AI 응답 생성 // 2. AI 응답 생성
responseText = await generateAIResponse(env, userId, chatIdStr, body.text, telegramUserId); 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. 봇 응답 버퍼에 추가 // 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
@@ -417,6 +423,209 @@ async function handleChatApi(request: Request, env: Env): Promise<Response> {
let responseText: string; 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:
`📋 <b>서버 주문 접수 완료!</b> (주문 #${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('/')) { if (body.message.startsWith('/')) {
const [command, ...argParts] = body.message.split(' '); const [command, ...argParts] = body.message.split(' ');
@@ -429,6 +638,12 @@ async function handleChatApi(request: Request, env: Env): Promise<Response> {
// 2. AI 응답 생성 // 2. AI 응답 생성
responseText = await generateAIResponse(env, userId, chatIdStr, body.message, telegramUserId); 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. 봇 응답 버퍼에 추가 // 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);

View File

@@ -9,69 +9,103 @@
* - Brave Search / Context7 도구로 최신 트렌드 반영 * - Brave Search / Context7 도구로 최신 트렌드 반영
*/ */
import type { Env, ServerSession, BandwidthInfo } from './types'; import type { Env, ServerSession, BandwidthInfo, RecommendResponse } from './types';
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; import { executeSearchWeb, executeLookupDocs } from './tools/search-tool';
import { formatTrafficInfo } from './utils/formatters'; import { formatTrafficInfo } from './utils/formatters';
const logger = createLogger('server-agent'); const logger = createLogger('server-agent');
// KV Session Management // D1 Session Management
const SESSION_TTL = 3600; // 1 hour const SESSION_TTL_MS = 3600 * 1000; // 1 hour in milliseconds
const SESSION_KEY_PREFIX = 'server_session:';
export async function getServerSession( export async function getServerSession(
kv: KVNamespace, db: D1Database,
userId: string userId: string
): Promise<ServerSession | null> { ): Promise<ServerSession | null> {
try { try {
const key = `${SESSION_KEY_PREFIX}${userId}`; const now = Date.now();
logger.info('세션 조회 시도', { userId, key }); const result = await db.prepare(
const data = await kv.get(key, 'json'); '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) { if (!result) {
logger.info('세션 없음', { userId, key }); logger.info('세션 없음', { userId });
return null; return null;
} }
logger.info('세션 조회 성공', { userId, key, status: (data as ServerSession).status }); const session: ServerSession = {
return data as 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) { } catch (error) {
logger.error('세션 조회 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` }); logger.error('세션 조회 실패', error as Error, { userId });
return null; return null;
} }
} }
export async function saveServerSession( export async function saveServerSession(
kv: KVNamespace, db: D1Database,
userId: string, userId: string,
session: ServerSession session: ServerSession
): Promise<void> { ): Promise<void> {
try { try {
const key = `${SESSION_KEY_PREFIX}${userId}`; const now = Date.now();
session.updatedAt = Date.now(); const expiresAt = now + SESSION_TTL_MS;
const sessionData = JSON.stringify(session); await db.prepare(`
logger.info('세션 저장 시도', { userId, key, status: session.status, dataLength: sessionData.length }); 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, { logger.info('세션 저장 성공', { userId, status: session.status });
expirationTtl: SESSION_TTL,
});
logger.info('세션 저장 성공', { userId, key, status: session.status });
} catch (error) { } catch (error) {
logger.error('세션 저장 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` }); logger.error('세션 저장 실패', error as Error, { userId });
throw error; throw error;
} }
} }
export async function deleteServerSession( export async function deleteServerSession(
kv: KVNamespace, db: D1Database,
userId: string userId: string
): Promise<void> { ): Promise<void> {
try { try {
const key = `${SESSION_KEY_PREFIX}${userId}`; await db.prepare('DELETE FROM server_sessions WHERE user_id = ?')
await kv.delete(key); .bind(userId)
.run();
logger.info('세션 삭제 성공', { userId }); logger.info('세션 삭제 성공', { userId });
} catch (error) { } catch (error) {
logger.error('세션 삭제 실패', error as Error, { userId }); logger.error('세션 삭제 실패', error as Error, { userId });
@@ -79,6 +113,23 @@ export async function deleteServerSession(
} }
} }
export async function cleanupExpiredSessions(db: D1Database): Promise<number> {
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 // Server Expert AI Tools
const serverExpertTools = [ 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 // Tech stack inference from use case
function inferTechStack(useCase: string): string[] { 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)) { if (/redis|memcached|cache/.test(lower)) {
return ['ecommerce']; return ['redis', 'nodejs'];
} }
if (/커뮤니티|게시판|forum|community/.test(useCaseLower)) { if (/mongodb|mongo/.test(lower)) {
return ['php', 'mysql']; 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 return ['web']; // Default
} }
// Expected users inference from scale // Expected users inference from scale
// Returns concurrent users (not DAU) // 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% 비율 적용) // DAU → 동시접속자 변환 (5-10% 비율 적용)
if (scale === 'personal') return 10; // DAU 100명 → 동접 10명 if (scale === 'personal') return 10; // DAU 100명 → 동접 10명
if (scale === 'business') return 50; // DAU 500명 → 동접 50명 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) // OpenAI 호출 (서버 전문가 AI with Function Calling)
async function callServerExpertAI( async function callServerExpertAI(
env: Env, env: Env,
@@ -339,7 +469,11 @@ ${session.collectedInfo.budgetLimit ? `- 예산: ${session.collectedInfo.budgetL
- 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명) - 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명)
- 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB - 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB
- 게임서버 → 고사양 CPU, 낮은 레이턴시 리전 - 게임서버 → 고사양 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)} ${JSON.stringify(session.collectedInfo, null, 2)}
@@ -385,6 +519,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
messages, messages,
tools: serverExpertTools, tools: serverExpertTools,
tool_choice: 'auto', tool_choice: 'auto',
response_format: { type: 'json_object' },
max_tokens: 800, max_tokens: 800,
temperature: 0.7, temperature: 0.7,
}; };
@@ -419,20 +554,22 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
tool_calls: assistantMessage.tool_calls, tool_calls: assistantMessage.tool_calls,
}); });
// Execute each tool and add results // Execute tools in parallel for better performance
for (const toolCall of assistantMessage.tool_calls) { const toolResults = await Promise.all(
assistantMessage.tool_calls.map(async (toolCall) => {
const args = JSON.parse(toolCall.function.arguments); const args = JSON.parse(toolCall.function.arguments);
const result = await executeServerExpertTool(toolCall.function.name, args, env); const result = await executeServerExpertTool(toolCall.function.name, args, env);
return {
messages.push({ role: 'tool' as const,
role: 'tool',
tool_call_id: toolCall.id, tool_call_id: toolCall.id,
name: toolCall.function.name, name: toolCall.function.name,
content: result, content: result,
}); };
})
);
toolCallCount++; messages.push(...toolResults);
} toolCallCount += toolResults.length;
// Continue loop to get AI's response with tool results // Continue loop to get AI's response with tool results
continue; continue;
@@ -458,10 +595,30 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
throw new Error('Invalid AI response structure'); 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 { return {
action: parsed.action, action: parsed.action,
message: parsed.message, message: parsed.message,
collectedInfo: parsed.collectedInfo || session.collectedInfo, collectedInfo: finalCollectedInfo,
}; };
} }
@@ -482,7 +639,8 @@ ${JSON.stringify(session.collectedInfo, null, 2)}
export async function processServerConsultation( export async function processServerConsultation(
userMessage: string, userMessage: string,
session: ServerSession, session: ServerSession,
env: Env env: Env,
sendIntermediateMessage?: (message: string) => Promise<void>
): Promise<string> { ): Promise<string> {
try { try {
logger.info('상담 처리 시작', { logger.info('상담 처리 시작', {
@@ -491,11 +649,22 @@ export async function processServerConsultation(
status: session.status 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()) || if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) ||
/취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) { /취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) {
await deleteServerSession(env.SESSION_KV, session.telegramUserId); await deleteServerSession(env.DB, session.telegramUserId);
logger.info('사용자 요청으로 상담 취소', { logger.info('사용자 요청으로 상담 취소', {
userId: session.telegramUserId, userId: session.telegramUserId,
previousStatus: session.status, previousStatus: session.status,
@@ -506,7 +675,7 @@ export async function processServerConsultation(
// "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋) // "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋)
if (/서버\s*추천/.test(userMessage)) { if (/서버\s*추천/.test(userMessage)) {
await deleteServerSession(env.SESSION_KV, session.telegramUserId); await deleteServerSession(env.DB, session.telegramUserId);
logger.info('서버 추천 키워드로 세션 리셋', { logger.info('서버 추천 키워드로 세션 리셋', {
userId: session.telegramUserId, userId: session.telegramUserId,
previousStatus: session.status previousStatus: session.status
@@ -520,17 +689,25 @@ export async function processServerConsultation(
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: 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 서버 등'; 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) { if (session.status === 'selecting' && session.lastRecommendation) {
// 상담과 무관한 키워드 감지 (selecting 상태에서만) // 상담과 무관한 키워드 감지 (selecting 상태에서만)
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/;
if (unrelatedPatterns.test(userMessage)) { if (unrelatedPatterns.test(userMessage)) {
await deleteServerSession(env.SESSION_KV, session.telegramUserId); await deleteServerSession(env.DB, session.telegramUserId);
logger.info('무관한 요청으로 세션 자동 종료', { logger.info('무관한 요청으로 세션 자동 종료', {
userId: session.telegramUserId, userId: session.telegramUserId,
message: userMessage.slice(0, 30) message: userMessage.slice(0, 30)
@@ -561,15 +738,21 @@ export async function processServerConsultation(
// Mark session as ordering // Mark session as ordering
session.status = 'ordering'; session.status = 'ordering';
await saveServerSession(env.SESSION_KV, session.telegramUserId, session); await saveServerSession(env.DB, session.telegramUserId, session);
// 주문 확인 메시지 생성 (인라인 버튼 포함) // 주문 확인 세션 저장 (텍스트 기반 확인)
const keyboardData = JSON.stringify({ const orderConfirmKey = `server_order_confirm:${session.telegramUserId}`;
type: 'server_order', const orderConfirmData = JSON.stringify({
userId: session.telegramUserId, userId: session.telegramUserId,
index: selectedIndex, 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 = ''; let trafficInfo = '';
@@ -586,18 +769,21 @@ export async function processServerConsultation(
gross_monthly_tb: selected.price.gross_monthly_tb, gross_monthly_tb: selected.price.gross_monthly_tb,
cdn_cache_hit_rate: selected.price.cdn_cache_hit_rate, 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` + return `🖥️ ${selected.plan_name} 신청 확인\n\n` +
`• 제공사: ${selected.provider}\n` + `• 제공사: ${selected.provider}\n` +
`• 스펙: ${selected.specs.vcpu}vCPU / ${selected.specs.ram_gb}GB RAM / ${selected.specs.storage_gb}GB SSD\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.region.name} (${selected.region.code})\n` +
`• 가격: ${selected.price.monthly_krw.toLocaleString()}/월\n` + `• 가격: ${priceDisplay}/월\n` +
`• 대역폭: ${selected.price.bandwidth_tb}TB 포함\n` + `• 대역폭: ${selected.price.bandwidth_tb}TB 포함\n` +
trafficInfo + trafficInfo +
`\n신청하시겠습니까?\n\n` + `\n⚠️ 정말 신청하시려면 '신청'이라고 입력하세요.\n` +
`__KEYBOARD__${keyboardData}__END__`; `(5분 내 응답 없으면 자동 취소됩니다)`;
} else { } else {
return `번호를 다시 확인해주세요. 1번부터 ${session.lastRecommendation.recommendations.length}번 중에서 선택해주세요.`; return `번호를 다시 확인해주세요. 1번부터 ${session.lastRecommendation.recommendations.length}번 중에서 선택해주세요.`;
} }
@@ -620,19 +806,44 @@ export async function processServerConsultation(
session.messages.push({ role: 'assistant', content: aiResult.message }); session.messages.push({ role: 'assistant', content: aiResult.message });
if (aiResult.action === 'recommend') { if (aiResult.action === 'recommend') {
// Send intermediate message to user
if (sendIntermediateMessage) {
await sendIntermediateMessage('🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.');
}
// Mark session as recommending // Mark session as recommending
session.status = 'recommending'; session.status = 'recommending';
await saveServerSession(env.SESSION_KV, session.telegramUserId, session); await saveServerSession(env.DB, session.telegramUserId, session);
// 1. Call recommendation API (추천 먼저 받기) // 1. Call recommendation API (추천 먼저 받기)
logger.info('추천 API 호출', { collectedInfo: session.collectedInfo }); logger.info('추천 API 호출', { collectedInfo: session.collectedInfo });
const { executeServerAction, getRecommendationData } = await import('./tools/server-tool'); 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) ? inferTechStack(session.collectedInfo.useCase)
: ['web']; : ['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 기반 추론 // 동시접속자 우선 사용, 없으면 scale 기반 추론
let expectedUsers = 10; // Default let expectedUsers = 10; // Default
const concurrent = Number(session.collectedInfo.expectedConcurrent) || 0; const concurrent = Number(session.collectedInfo.expectedConcurrent) || 0;
@@ -644,7 +855,19 @@ export async function processServerConsultation(
// DAU가 있으면 10% 비율로 동시접속자 계산 // DAU가 있으면 10% 비율로 동시접속자 계산
expectedUsers = Math.ceil(dau * 0.1); expectedUsers = Math.ceil(dau * 0.1);
} else if (session.collectedInfo.scale) { } 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( const recommendationData = await getRecommendationData(
@@ -652,7 +875,7 @@ export async function processServerConsultation(
tech_stack: techStack, tech_stack: techStack,
expected_users: expectedUsers, expected_users: expectedUsers,
use_case: session.collectedInfo.useCase || '웹 서비스', use_case: session.collectedInfo.useCase || '웹 서비스',
region_preference: session.collectedInfo.regionPreference, region_preference: finalRegionPreference,
budget_limit: session.collectedInfo.budgetLimit, budget_limit: session.collectedInfo.budgetLimit,
lang: 'ko', lang: 'ko',
}, },
@@ -663,6 +886,7 @@ export async function processServerConsultation(
if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) { if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) {
session.lastRecommendation = { session.lastRecommendation = {
recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ 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, plan_name: rec.server.instance_name,
provider: rec.server.provider_name, provider: rec.server.provider_name,
specs: { specs: {
@@ -681,7 +905,8 @@ export async function processServerConsultation(
gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb, gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb,
cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate, cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate,
overage_tb: rec.bandwidth_info?.estimated_overage_tb, 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, score: rec.score,
max_users: rec.estimated_capacity?.max_concurrent_users || 0 max_users: rec.estimated_capacity?.max_concurrent_users || 0
@@ -710,21 +935,22 @@ export async function processServerConsultation(
// Mark session as selecting (사용자 선택 대기) // Mark session as selecting (사용자 선택 대기)
session.status = 'selecting'; session.status = 'selecting';
await saveServerSession(env.SESSION_KV, session.telegramUserId, session); await saveServerSession(env.DB, session.telegramUserId, session);
// 4. AI 검토 코멘트 + 추천 결과 함께 반환 // 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에)
return `${reviewResult.message}\n\n${formattedRecommendation}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; // __DIRECT__ 마커가 앞에 와야 제대로 처리됨
return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`;
} else { } else {
// 추천 결과 없음 - 세션 삭제 // 추천 결과 없음 - 세션 삭제
session.status = 'completed'; session.status = 'completed';
await deleteServerSession(env.SESSION_KV, session.telegramUserId); await deleteServerSession(env.DB, session.telegramUserId);
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
} }
} else { } else {
// Continue gathering information // Continue gathering information
session.status = 'gathering'; session.status = 'gathering';
await saveServerSession(env.SESSION_KV, session.telegramUserId, session); await saveServerSession(env.DB, session.telegramUserId, session);
return aiResult.message; return aiResult.message;
} }
@@ -732,7 +958,7 @@ export async function processServerConsultation(
logger.error('상담 처리 실패', error as Error, { userId: session.telegramUserId }); logger.error('상담 처리 실패', error as Error, { userId: session.telegramUserId });
// Clean up session on error // Clean up session on error
await deleteServerSession(env.SESSION_KV, session.telegramUserId); await deleteServerSession(env.DB, session.telegramUserId);
return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.'; return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.';
} }

View File

@@ -5,6 +5,9 @@ import {
generateAIResponse, generateAIResponse,
} from '../summary-service'; } from '../summary-service';
import { sendChatAction } from '../telegram'; import { sendChatAction } from '../telegram';
import { createLogger } from '../utils/logger';
const logger = createLogger('conversation');
export interface ConversationResult { export interface ConversationResult {
responseText: string; responseText: string;
@@ -26,7 +29,9 @@ export class ConversationService {
telegramUserId: string telegramUserId: string
): Promise<ConversationResult> { ): Promise<ConversationResult> {
// 1. 타이핑 액션 전송 (비동기로 실행, 기다리지 않음) // 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. 사용자 메시지 버퍼에 추가 // 2. 사용자 메시지 버퍼에 추가
await addToBuffer(this.env.DB, userId, chatId, 'user', text); await addToBuffer(this.env.DB, userId, chatId, 'user', text);
@@ -40,6 +45,12 @@ export class ConversationService {
telegramUserId telegramUserId
); );
// __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비)
if (responseText.includes('__DIRECT__')) {
const directIndex = responseText.indexOf('__DIRECT__');
responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
}
// 4. 봇 응답 버퍼에 추가 (키보드 데이터 마커 등은 그대로 저장) // 4. 봇 응답 버퍼에 추가 (키보드 데이터 마커 등은 그대로 저장)
// 실제 사용자에게 보여질 텍스트만 저장하는 것이 좋으나, // 실제 사용자에게 보여질 텍스트만 저장하는 것이 좋으나,
// 현재 구조상 전체를 저장하고 나중에 컨텍스트로 활용 시 정제될 수 있음 // 현재 구조상 전체를 저장하고 나중에 컨텍스트로 활용 시 정제될 수 있음
@@ -58,18 +69,18 @@ export class ConversationService {
const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s); const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s);
if (keyboardMatch) { 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, ''); responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/s, '');
try { try {
keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData; keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData;
console.log('[ConversationService] Keyboard parsed successfully:', keyboardData.type); logger.debug('키보드 파싱 성공', { type: keyboardData.type });
} catch (e) { } catch (e) {
console.error('[ConversationService] Keyboard parsing error:', e); logger.error('키보드 파싱 오류', e as Error, { rawData: keyboardMatch[1] });
console.error('[ConversationService] Failed to parse:', keyboardMatch[1]);
} }
} else if (responseText.includes('__KEYBOARD__')) { } else if (responseText.includes('__KEYBOARD__')) {
console.warn('[ConversationService] Keyboard marker found but regex did not match'); logger.warn('키보드 마커 발견했으나 정규식 매칭 실패', {
console.warn('[ConversationService] Response preview:', responseText.substring(0, 200)); preview: responseText.substring(0, 200)
});
} }
return { return {

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@ export function formatTB(value: number): string {
* - CDN 없음 + 초과: "예상 트래픽: 1.50TB → 초과 0.50TB (₩10,000)" * - CDN 없음 + 초과: "예상 트래픽: 1.50TB → 초과 0.50TB (₩10,000)"
* - CDN 없음 + 포함: "예상 트래픽: 0.80TB (포함 범위 내)" * - CDN 없음 + 포함: "예상 트래픽: 0.80TB (포함 범위 내)"
*/ */
export function formatTrafficInfo(bandwidth_info: BandwidthInfo, currency: string): string { export function formatTrafficInfo(bandwidth_info: BandwidthInfo): string {
// CDN 정보가 있는 경우 // CDN 정보가 있는 경우
if (bandwidth_info.gross_monthly_tb !== undefined && bandwidth_info.cdn_cache_hit_rate !== undefined) { if (bandwidth_info.gross_monthly_tb !== undefined && bandwidth_info.cdn_cache_hit_rate !== undefined) {
// CDN 캐시 히트율 범위 검증 (0-100%) // 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) { if (bandwidth_info.estimated_overage_tb > 0 && bandwidth_info.estimated_overage_cost > 0) {
const overageTB = formatTB(bandwidth_info.estimated_overage_tb); const overageTB = formatTB(bandwidth_info.estimated_overage_tb);
const overageCost = currency === 'KRW' const overageCost = `${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}`;
? `${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}`
: `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`;
return `예상 트래픽: ${grossTB} (CDN ${hitRate}% → 원본 ${estimatedTB}) → 초과 ${overageTB} (${overageCost})`; return `예상 트래픽: ${grossTB} (CDN ${hitRate}% → 원본 ${estimatedTB}) → 초과 ${overageTB} (${overageCost})`;
} else { } else {
return `예상 트래픽: ${grossTB} (CDN ${hitRate}% → 원본 ${estimatedTB})`; 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) { if (bandwidth_info.estimated_overage_tb > 0 && bandwidth_info.estimated_overage_cost > 0) {
const overageTB = formatTB(bandwidth_info.estimated_overage_tb); const overageTB = formatTB(bandwidth_info.estimated_overage_tb);
const overageCost = currency === 'KRW' const overageCost = `${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}`;
? `${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}`
: `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`;
return `예상 트래픽: ${estimatedTB} → 초과 ${overageTB} (${overageCost})`; return `예상 트래픽: ${estimatedTB} → 초과 ${overageTB} (${overageCost})`;
} else { } else {
return `예상 트래픽: ${estimatedTB} (포함 범위 내)`; return `예상 트래픽: ${estimatedTB} (포함 범위 내)`;