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:
@@ -284,8 +284,8 @@ async function handleDepositDeduct(request: Request, env: Env): Promise<Response
|
||||
* @returns JSON response with AI 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 });
|
||||
}
|
||||
|
||||
@@ -310,8 +310,8 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
|
||||
|
||||
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<Response> {
|
||||
// 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<Response> {
|
||||
|
||||
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('/')) {
|
||||
const [command, ...argParts] = body.message.split(' ');
|
||||
@@ -429,6 +638,12 @@ async function handleChatApi(request: Request, env: Env): Promise<Response> {
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -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<ServerSession | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<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
|
||||
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<void>
|
||||
): Promise<string> {
|
||||
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다시 시도하려면 "서버 추천"이라고 말씀해주세요.';
|
||||
}
|
||||
|
||||
@@ -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<ConversationResult> {
|
||||
// 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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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} (포함 범위 내)`;
|
||||
|
||||
Reference in New Issue
Block a user