- Create src/agents/ directory for agent modules - Move server-agent.ts to new location - Update import paths in all dependent files: - openai-service.ts - tools/server-tool.ts - routes/handlers/message-handler.ts - routes/api/chat.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1500 lines
49 KiB
TypeScript
1500 lines
49 KiB
TypeScript
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');
|
||
|
||
// Generate idempotency key for order requests
|
||
// Format: tg-order-{userId}-{timestamp}-{random}
|
||
function generateIdempotencyKey(userId: string): string {
|
||
const timestamp = Date.now();
|
||
const random = Math.random().toString(36).substring(2, 10);
|
||
return `tg-order-${userId}-${timestamp}-${random}`;
|
||
}
|
||
|
||
// CDN 캐시 히트율 상수
|
||
const CDN_CACHE_HIT_RATES = {
|
||
VIDEO_STREAMING: 0.92,
|
||
STATIC_SITE: 0.95,
|
||
API: 0.30,
|
||
ECOMMERCE: 0.70,
|
||
DEFAULT: 0.85,
|
||
} as const;
|
||
|
||
// 언어 감지 (한글/일본어/중국어/영어)
|
||
function detectLanguage(text: string): 'ko' | 'ja' | 'zh' | 'en' {
|
||
if (/[가-힣]/.test(text)) return 'ko';
|
||
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'ja'; // 히라가나/가타카나
|
||
if (/[\u4e00-\u9fff]/.test(text)) return 'zh'; // 한자 (일본어 감지 후)
|
||
return 'en';
|
||
}
|
||
|
||
// CDN 캐시 히트율 추정 (tech_stack + use_case 기반)
|
||
function estimateCdnCacheHitRate(techStack: string[], useCase: string): number | null {
|
||
const stackLower = techStack.map(s => s.toLowerCase());
|
||
const useCaseLower = useCase.toLowerCase();
|
||
|
||
// CDN 키워드 감지
|
||
const hasCdn = stackLower.some(s =>
|
||
['cloudflare', 'cdn', 'fastly', 'akamai', 'bunny', 'cf'].includes(s)
|
||
);
|
||
|
||
if (!hasCdn) return null; // CDN 없으면 null 반환
|
||
|
||
// use_case 기반 히트율 조정
|
||
const isVideoStreaming = /video|streaming|vod|media|동영상|스트리밍|미디어/.test(useCaseLower);
|
||
const isStaticSite = /static|blog|portfolio|landing|정적|블로그/.test(useCaseLower);
|
||
const isApi = /api|backend|서버|백엔드/.test(useCaseLower);
|
||
const isEcommerce = /shop|store|commerce|쇼핑|이커머스/.test(useCaseLower);
|
||
|
||
// 콘텐츠 타입별 예상 캐시 히트율
|
||
if (isVideoStreaming) return CDN_CACHE_HIT_RATES.VIDEO_STREAMING;
|
||
if (isStaticSite) return CDN_CACHE_HIT_RATES.STATIC_SITE;
|
||
if (isApi) return CDN_CACHE_HIT_RATES.API;
|
||
if (isEcommerce) return CDN_CACHE_HIT_RATES.ECOMMERCE;
|
||
|
||
return CDN_CACHE_HIT_RATES.DEFAULT;
|
||
}
|
||
|
||
// Type guards
|
||
function isErrorResult(result: unknown): result is { error: string } {
|
||
return typeof result === 'object' && result !== null && 'error' in result;
|
||
}
|
||
|
||
// 진행 중인 주문 확인 (중복 주문 방지)
|
||
async function checkExistingOrder(
|
||
db: D1Database,
|
||
telegramUserId: string
|
||
): Promise<{ id: number; status: string; label: string | null } | null> {
|
||
const result = await db.prepare(
|
||
`SELECT id, status, label FROM server_orders
|
||
WHERE telegram_user_id = ?
|
||
AND status IN ('pending', 'provisioning')
|
||
LIMIT 1`
|
||
).bind(telegramUserId).first<{ id: number; status: string; label: string | null }>();
|
||
|
||
return result || null;
|
||
}
|
||
|
||
export const manageServerTool = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'manage_server',
|
||
description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 추천(action="start_consultation"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
action: {
|
||
type: 'string',
|
||
enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot',
|
||
'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename'],
|
||
description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, start_consultation: 상담 시작, rename: 이름 변경',
|
||
},
|
||
tech_stack: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
description: '기술 스택. 용도에서 추론 (블로그→wordpress, 쇼핑몰→ecommerce, 커뮤니티→php,mysql). 모르면 ["web"]',
|
||
},
|
||
expected_users: {
|
||
type: 'number',
|
||
description: '예상 사용자 수. 모르면 개인용=100, 사업용=500 사용',
|
||
},
|
||
use_case: {
|
||
type: 'string',
|
||
description: '용도 (예: "블로그", "쇼핑몰", "커뮤니티")',
|
||
},
|
||
traffic_pattern: {
|
||
type: 'string',
|
||
enum: ['steady', 'spiky', 'growing'],
|
||
description: '생략 가능. 기본값: steady',
|
||
},
|
||
region_preference: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
description: '선호 리전 (예: ["tokyo", "seoul"]). recommend action에서 선택',
|
||
},
|
||
budget_limit: {
|
||
type: 'number',
|
||
description: '월 예산 한도 (원). recommend action에서 선택',
|
||
},
|
||
lang: {
|
||
type: 'string',
|
||
enum: ['ko', 'ja', 'zh', 'en'],
|
||
description: '응답 언어. 자동 감지됨',
|
||
},
|
||
server_id: {
|
||
type: 'string',
|
||
description: '서버 ID (레거시, 사용 안 함)',
|
||
},
|
||
region_code: {
|
||
type: 'string',
|
||
description: '리전 코드 (레거시, 사용 안 함)',
|
||
},
|
||
label: {
|
||
type: 'string',
|
||
description: '서버 라벨 (예: "myapp-prod"). order action에서 필수',
|
||
},
|
||
message: {
|
||
type: 'string',
|
||
description: '사용자 메시지. continue_consultation action에서 필수',
|
||
},
|
||
pricing_id: {
|
||
type: 'number',
|
||
description: 'Pricing ID (서버 스펙 ID). order action에서 필수',
|
||
},
|
||
order_id: {
|
||
type: 'number',
|
||
description: '주문 번호. info, delete, rename action에서 필수',
|
||
},
|
||
new_label: {
|
||
type: 'string',
|
||
description: '새 서버 이름. rename action에서 필수',
|
||
},
|
||
image: {
|
||
type: 'string',
|
||
description: 'OS 이미지 키 (예: "ubuntu_22_04"). order action에서 선택 (기본값 사용 가능)',
|
||
},
|
||
},
|
||
required: ['action'],
|
||
},
|
||
},
|
||
};
|
||
|
||
// Cloud Orchestrator API 호출
|
||
async function callCloudOrchestratorApi(
|
||
endpoint: string,
|
||
method: string,
|
||
body?: Record<string, unknown>,
|
||
env?: Env
|
||
): Promise<unknown> {
|
||
logger.info('API 호출 시작', {
|
||
endpoint,
|
||
method,
|
||
useServiceBinding: !!env?.CLOUD_ORCHESTRATOR
|
||
});
|
||
|
||
try {
|
||
const response = await retryWithBackoff(
|
||
() => {
|
||
const requestInit = {
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
};
|
||
|
||
// Service Binding 우선, fallback: URL
|
||
if (env?.CLOUD_ORCHESTRATOR) {
|
||
logger.info('Service Binding 사용', { endpoint });
|
||
return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${endpoint}`, requestInit);
|
||
} else {
|
||
const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev';
|
||
const url = `${apiUrl}${endpoint}`;
|
||
logger.info('HTTP 요청 사용', { url });
|
||
return fetch(url, requestInit);
|
||
}
|
||
},
|
||
{ maxRetries: 3, serviceName: 'cloud-orchestrator' }
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
logger.error('API 호출 실패', new Error(errorText), {
|
||
endpoint,
|
||
status: response.status,
|
||
});
|
||
return { error: `서버 API 호출 실패: HTTP ${response.status}` };
|
||
}
|
||
|
||
const data = await response.json();
|
||
logger.info('API 호출 성공', { endpoint });
|
||
return data;
|
||
} catch (error) {
|
||
logger.error('API 호출 에러', error as Error, { endpoint });
|
||
if (error instanceof RetryError) {
|
||
return { error: ERROR_MESSAGES.SERVER_SERVICE_UNAVAILABLE };
|
||
}
|
||
return { error: '서버 API 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' };
|
||
}
|
||
}
|
||
|
||
// Cloud Orchestrator Provision API 호출
|
||
async function callProvisionAPI(
|
||
endpoint: string,
|
||
method: 'GET' | 'POST' | 'DELETE',
|
||
env: Env,
|
||
body?: Record<string, unknown>,
|
||
userId?: string
|
||
): Promise<ProvisionResponse> {
|
||
provisionLogger.info('API 호출 시작', {
|
||
endpoint,
|
||
method,
|
||
userId: maskUserId(userId),
|
||
useServiceBinding: !!env?.CLOUD_ORCHESTRATOR
|
||
});
|
||
|
||
try {
|
||
const response = await retryWithBackoff(
|
||
() => {
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
};
|
||
|
||
// Add API Key if available
|
||
if (env?.PROVISION_API_KEY) {
|
||
headers['X-API-Key'] = env.PROVISION_API_KEY;
|
||
}
|
||
|
||
const requestInit = {
|
||
method,
|
||
headers,
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
};
|
||
|
||
// Add userId as query param for GET/DELETE/POST
|
||
let fullEndpoint = endpoint;
|
||
if (userId && !endpoint.includes('?')) {
|
||
fullEndpoint = `${endpoint}?user_id=${userId}`;
|
||
} else if (userId && endpoint.includes('?')) {
|
||
fullEndpoint = `${endpoint}&user_id=${userId}`;
|
||
}
|
||
|
||
// Service Binding 우선, fallback: HTTP
|
||
if (env?.CLOUD_ORCHESTRATOR) {
|
||
provisionLogger.info('Service Binding 사용', { endpoint: fullEndpoint });
|
||
return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${fullEndpoint}`, requestInit);
|
||
} else {
|
||
const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev';
|
||
const url = `${apiUrl}${fullEndpoint}`;
|
||
provisionLogger.info('HTTP 요청 사용 (fallback)', { url });
|
||
return fetch(url, requestInit);
|
||
}
|
||
},
|
||
{ maxRetries: 3, serviceName: 'cloud-orchestrator-provision' }
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
provisionLogger.error('API 호출 실패', new Error(errorText), {
|
||
endpoint,
|
||
status: response.status,
|
||
});
|
||
|
||
// JSON 응답에서 오류 메시지 추출
|
||
let errorMessage = `HTTP ${response.status}`;
|
||
try {
|
||
const errorJson = JSON.parse(errorText);
|
||
if (errorJson.error) {
|
||
errorMessage = errorJson.error;
|
||
}
|
||
} catch {
|
||
// JSON 파싱 실패 시 텍스트 그대로 사용
|
||
if (errorText && errorText.length < 200) {
|
||
errorMessage = errorText;
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: false,
|
||
error: `프로비저닝 API 호출 실패: ${errorMessage}`,
|
||
};
|
||
}
|
||
|
||
const data = await response.json() as ProvisionResponse;
|
||
provisionLogger.info('API 호출 성공', { endpoint, success: data.success });
|
||
return data;
|
||
} catch (error) {
|
||
provisionLogger.error('API 호출 에러', error as Error, { endpoint });
|
||
if (error instanceof RetryError) {
|
||
return {
|
||
success: false,
|
||
error: ERROR_MESSAGES.SERVER_SERVICE_UNAVAILABLE,
|
||
};
|
||
}
|
||
return {
|
||
success: false,
|
||
error: '프로비저닝 API 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||
};
|
||
}
|
||
}
|
||
|
||
// 추천 데이터 조회 (포맷팅 없이 원본 반환)
|
||
export async function getRecommendationData(
|
||
args: {
|
||
tech_stack: string[];
|
||
expected_users: number;
|
||
use_case: string;
|
||
traffic_pattern?: string;
|
||
region_preference?: string[];
|
||
budget_limit?: number;
|
||
lang?: string;
|
||
},
|
||
env?: Env
|
||
): Promise<RecommendResponse | null> {
|
||
const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args;
|
||
|
||
// 언어 자동 감지
|
||
const detectedLang = lang || detectLanguage(use_case);
|
||
|
||
// CDN 캐시 히트율 추정
|
||
const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case);
|
||
|
||
// API 요청 body 구성
|
||
const requestBody: Record<string, unknown> = {
|
||
tech_stack,
|
||
expected_users,
|
||
use_case,
|
||
lang: detectedLang,
|
||
};
|
||
|
||
if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern;
|
||
if (region_preference) requestBody.region_preference = region_preference;
|
||
if (budget_limit) requestBody.budget_limit = budget_limit;
|
||
if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate;
|
||
|
||
// API 호출
|
||
const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env);
|
||
|
||
if (isErrorResult(result)) {
|
||
logger.error('추천 데이터 조회 실패', new Error(result.error));
|
||
return null;
|
||
}
|
||
|
||
return result as RecommendResponse;
|
||
}
|
||
|
||
// 서버 추천 결과 포맷팅 (필수 정보만)
|
||
// __DIRECT__ 마커로 AI 재해석 없이 바로 반환
|
||
function formatRecommendations(data: RecommendResponse): string {
|
||
if (!data.recommendations || data.recommendations.length === 0) {
|
||
return '🔍 조건에 맞는 서버를 찾지 못했습니다.\n다른 조건으로 다시 시도해주세요.';
|
||
}
|
||
|
||
const { recommendations } = data;
|
||
|
||
// __DIRECT__ 마커 추가 - AI 재해석 방지
|
||
let response = '__DIRECT__\n🖥️ 서버 추천 결과\n\n';
|
||
|
||
recommendations.slice(0, 3).forEach((rec, index) => {
|
||
const server = rec.server;
|
||
|
||
// 가격 포맷팅 (항상 KRW로 표시)
|
||
const price = `₩${Math.round(server.monthly_price).toLocaleString()}`;
|
||
|
||
// 대역폭 포맷팅
|
||
const bandwidth = server.transfer_tb ? `${server.transfer_tb}TB` : '무제한';
|
||
|
||
response += `${index + 1}️⃣ ${server.instance_name} (${server.provider_name})\n`;
|
||
response += ` • 스펙: ${server.vcpu}vCPU / ${server.memory_gb}GB / ${server.storage_gb}GB SSD\n`;
|
||
response += ` • 리전: ${server.region_name} (${server.country_code})\n`;
|
||
response += ` • 가격: ${price}/월 (대역폭 ${bandwidth})\n`;
|
||
|
||
// 대역폭 정보 (항상 표시)
|
||
if (rec.bandwidth_info) {
|
||
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 = `₩${Math.round(rec.bandwidth_info.total_estimated_cost).toLocaleString()}`;
|
||
response += ` • 총 예상 비용: ${totalCost}/월\n`;
|
||
}
|
||
}
|
||
|
||
response += ` • 점수: ${rec.score}점`;
|
||
if (rec.estimated_capacity) {
|
||
response += ` / 최대 ${rec.estimated_capacity.max_concurrent_users.toLocaleString()}명\n`;
|
||
} else {
|
||
response += '\n';
|
||
}
|
||
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':
|
||
case 'terminated':
|
||
return '🗑️';
|
||
case 'failed':
|
||
return '❌';
|
||
case 'pending':
|
||
return '⏳'; // 내부용, UI에 표시 안 함
|
||
default:
|
||
return '⚪';
|
||
}
|
||
}
|
||
|
||
// 서버 상태 텍스트
|
||
function getStatusText(status: string): string {
|
||
switch (status) {
|
||
case 'active':
|
||
return '가동 중';
|
||
case 'provisioning':
|
||
return '생성 중...';
|
||
case 'stopped':
|
||
return '중지됨';
|
||
case 'deleted':
|
||
case 'terminated':
|
||
return '삭제됨';
|
||
case 'failed':
|
||
return '실패';
|
||
default:
|
||
return '알 수 없음';
|
||
}
|
||
}
|
||
|
||
// 날짜 포맷팅 (YYYY-MM-DD)
|
||
function formatDate(dateString: string): string {
|
||
try {
|
||
const date = new Date(dateString);
|
||
return date.toISOString().split('T')[0];
|
||
} catch {
|
||
return dateString;
|
||
}
|
||
}
|
||
|
||
// 남은 시간 계산 (만료일 기준)
|
||
function calculateRemainingTime(expiresAt: string): string {
|
||
try {
|
||
const expiresTime = new Date(expiresAt).getTime();
|
||
const now = Date.now();
|
||
const remainingMs = expiresTime - now;
|
||
|
||
if (remainingMs <= 0) {
|
||
return '만료됨';
|
||
}
|
||
|
||
const remainingHours = Math.floor(remainingMs / (1000 * 60 * 60));
|
||
const remainingDays = Math.floor(remainingHours / 24);
|
||
const hours = remainingHours % 24;
|
||
|
||
if (remainingDays > 0) {
|
||
return `${remainingDays}일 ${hours}시간`;
|
||
} else {
|
||
return `${hours}시간`;
|
||
}
|
||
} catch {
|
||
return '알 수 없음';
|
||
}
|
||
}
|
||
|
||
// 만료일 포맷팅 (YYYY-MM-DD HH:MM)
|
||
function formatExpiry(expiresAt: string): string {
|
||
try {
|
||
const date = new Date(expiresAt);
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
const hours = String(date.getHours()).padStart(2, '0');
|
||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||
} catch {
|
||
return expiresAt;
|
||
}
|
||
}
|
||
|
||
// 서버 목록 포맷팅
|
||
function formatServerList(orders: ProvisionOrder[]): string {
|
||
// 'pending' 상태는 내부용 (Queue 대기), UI에 표시하지 않음
|
||
// 'provisioning' 또는 'active' 상태만 표시
|
||
// 'terminated', 'deleted', 'failed' 상태는 제외
|
||
const activeOrders = orders?.filter(order => {
|
||
// pending은 표시하지 않음 (내부 Queue 상태)
|
||
if (order.status === 'pending') {
|
||
return false;
|
||
}
|
||
|
||
// provisioning 또는 active만 표시
|
||
if (!['provisioning', 'active'].includes(order.status)) {
|
||
return false;
|
||
}
|
||
|
||
// active 상태인데 provider_instance_id가 없으면 제외 (프로비저닝 실패)
|
||
if (order.status === 'active' && !order.provider_instance_id) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}) || [];
|
||
|
||
if (activeOrders.length === 0) {
|
||
return '🖥️ 등록된 서버가 없습니다.\n\n서버를 추천받으려면 "서버 추천"이라고 말씀해주세요.';
|
||
}
|
||
|
||
let response = '__DIRECT__\n🖥️ 내 서버 목록\n\n';
|
||
|
||
activeOrders.forEach((order) => {
|
||
const statusEmoji = getStatusEmoji(order.status);
|
||
const statusText = getStatusText(order.status);
|
||
const label = order.label || '(라벨 없음)';
|
||
|
||
response += `#${order.id} ${statusEmoji} ${label}\n`;
|
||
if (order.ip_address) {
|
||
response += ` • IP: ${order.ip_address}\n`;
|
||
}
|
||
response += ` • 상태: ${statusText}\n`;
|
||
response += ` • 생성일: ${formatDate(order.created_at)}\n`;
|
||
|
||
// 만료일 표시 (있을 경우)
|
||
if (order.expires_at) {
|
||
const remainingTime = calculateRemainingTime(order.expires_at);
|
||
response += ` • 만료일: ${formatExpiry(order.expires_at)}\n`;
|
||
response += ` • 남은 시간: ${remainingTime}\n`;
|
||
}
|
||
|
||
response += '\n';
|
||
});
|
||
|
||
response += '💡 서버 관리: "N번 시작/중지" 또는 "#N 재시작"';
|
||
|
||
return response.trim();
|
||
}
|
||
|
||
// 서버 상세 정보 포맷팅
|
||
function formatServerInfo(order: ProvisionOrder): string {
|
||
const statusEmoji = getStatusEmoji(order.status);
|
||
const statusText = getStatusText(order.status);
|
||
|
||
let response = '__DIRECT__\n🖥️ 서버 상세 정보\n\n';
|
||
|
||
response += '📋 기본 정보\n';
|
||
response += `• 주문번호: #${order.id}\n`;
|
||
response += `• 라벨: ${order.label}\n`;
|
||
response += `• 상태: ${statusEmoji} ${statusText}\n\n`;
|
||
|
||
if (order.status === 'active' && order.ip_address) {
|
||
response += '🔗 접속 정보\n';
|
||
response += `• IP: ${order.ip_address}\n`;
|
||
if (order.image) {
|
||
response += `• OS: ${order.image.replace(/_/g, ' ')}\n`;
|
||
}
|
||
if (order.root_password) {
|
||
response += `• Password: ${order.root_password}\n\n`;
|
||
response += '⚠️ 비밀번호는 안전한 곳에 보관하세요.\n';
|
||
}
|
||
} else if (order.status === 'provisioning') {
|
||
response += '⏳ 서버 생성 중입니다.\n잠시 후 다시 조회해주세요.\n';
|
||
}
|
||
|
||
return response.trim();
|
||
}
|
||
|
||
// OS 이미지 목록 포맷팅
|
||
function formatImageList(images: OSImage[]): string {
|
||
if (!images || images.length === 0) {
|
||
return '📀 사용 가능한 OS 이미지가 없습니다.';
|
||
}
|
||
|
||
let response = '__DIRECT__\n📀 사용 가능한 OS 이미지\n\n';
|
||
|
||
images.forEach((image) => {
|
||
// is_default can be boolean or number (1/0)
|
||
const isDefault = image.is_default === true || image.is_default === 1;
|
||
const marker = isDefault ? '✓ ' : ' ';
|
||
const defaultText = isDefault ? ' (기본)' : '';
|
||
response += `${marker}${image.name}${defaultText}\n`;
|
||
});
|
||
|
||
return response.trim();
|
||
}
|
||
|
||
// 입금 계좌 정보
|
||
const DEPOSIT_ACCOUNT_INFO = `💳 입금 계좌
|
||
하나은행 427-910018-27104
|
||
예금주: (주)아이언클래드
|
||
|
||
입금 후 "입금 신고"로 알려주세요.`;
|
||
|
||
// 서버 작업 직접 실행
|
||
export async function executeServerAction(
|
||
action: string,
|
||
args: {
|
||
tech_stack?: string[];
|
||
expected_users?: number;
|
||
use_case?: string;
|
||
traffic_pattern?: string;
|
||
region_preference?: string[];
|
||
budget_limit?: number;
|
||
lang?: string;
|
||
server_id?: string;
|
||
region_code?: string;
|
||
label?: string;
|
||
message?: string;
|
||
pricing_id?: number;
|
||
order_id?: number;
|
||
new_label?: string;
|
||
image?: string;
|
||
},
|
||
env?: Env,
|
||
telegramUserId?: string
|
||
): Promise<string> {
|
||
logger.info('작업 시작', {
|
||
action,
|
||
userId: maskUserId(telegramUserId),
|
||
args: JSON.stringify(args).slice(0, 200),
|
||
});
|
||
|
||
switch (action) {
|
||
case 'start_consultation': {
|
||
// Import session functions
|
||
const { saveServerSession } = await import('../agents/server-agent');
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!env?.DB) {
|
||
return '🚫 세션 저장소가 설정되지 않았습니다.';
|
||
}
|
||
|
||
const session: import('../types').ServerSession = {
|
||
telegramUserId,
|
||
status: 'gathering',
|
||
collectedInfo: {},
|
||
messages: [],
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
};
|
||
|
||
await saveServerSession(env.DB, telegramUserId, session);
|
||
|
||
logger.info('상담 세션 생성', { userId: maskUserId(telegramUserId) });
|
||
|
||
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
|
||
}
|
||
|
||
case 'continue_consultation': {
|
||
const { getServerSession, processServerConsultation } = await import('../agents/server-agent');
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!env?.DB) {
|
||
return '🚫 세션 저장소가 설정되지 않았습니다.';
|
||
}
|
||
|
||
if (!args.message) {
|
||
return '🚫 메시지가 필요합니다.';
|
||
}
|
||
|
||
const session = await getServerSession(env.DB, telegramUserId);
|
||
if (!session) {
|
||
return '세션이 만료되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
|
||
}
|
||
|
||
const result = await processServerConsultation(args.message, session, env);
|
||
return result;
|
||
}
|
||
|
||
case 'cancel_consultation': {
|
||
const { deleteServerSession } = await import('../agents/server-agent');
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!env?.DB) {
|
||
return '🚫 세션 저장소가 설정되지 않았습니다.';
|
||
}
|
||
|
||
await deleteServerSession(env.DB, telegramUserId);
|
||
|
||
logger.info('상담 세션 취소', { userId: maskUserId(telegramUserId) });
|
||
|
||
return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
|
||
}
|
||
|
||
case 'recommend': {
|
||
const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args;
|
||
|
||
// 필수 파라미터 검증
|
||
if (!tech_stack || !expected_users || !use_case) {
|
||
return '🚫 서버 추천에는 tech_stack, expected_users, use_case가 필요합니다.';
|
||
}
|
||
|
||
// 언어 자동 감지 (use_case 기반)
|
||
const detectedLang = lang || detectLanguage(use_case);
|
||
|
||
// CDN 캐시 히트율 추정
|
||
const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case);
|
||
|
||
// API 요청 body 구성
|
||
const requestBody: Record<string, unknown> = {
|
||
tech_stack,
|
||
expected_users,
|
||
use_case,
|
||
lang: detectedLang,
|
||
};
|
||
|
||
if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern;
|
||
if (region_preference) requestBody.region_preference = region_preference;
|
||
if (budget_limit) requestBody.budget_limit = budget_limit;
|
||
if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate;
|
||
|
||
// API 호출
|
||
const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env);
|
||
|
||
if (isErrorResult(result)) {
|
||
return `🚫 ${result.error}`;
|
||
}
|
||
|
||
const recommendationData = result as RecommendResponse;
|
||
|
||
// 세션에 추천 결과 저장 (선택 기능 활성화)
|
||
if (telegramUserId && env?.DB && recommendationData.recommendations && recommendationData.recommendations.length > 0) {
|
||
try {
|
||
const { getServerSession, saveServerSession } = await import('../agents/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(recommendationData);
|
||
}
|
||
|
||
case 'order': {
|
||
const { pricing_id, label, image } = args;
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!pricing_id || !label) {
|
||
return '🚫 서버 주문에는 pricing_id와 label이 필요합니다.';
|
||
}
|
||
|
||
if (!env) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
// 중복 주문 방지: 진행 중인 주문 확인
|
||
if (env.DB) {
|
||
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
|
||
if (existingOrder) {
|
||
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
|
||
return `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
|
||
`• 주문 번호: #${existingOrder.id}\n` +
|
||
`• 라벨: ${existingOrder.label || '없음'}\n` +
|
||
`• 상태: ${statusText}\n\n` +
|
||
`완료 후 다시 시도해주세요.`;
|
||
}
|
||
}
|
||
|
||
// Check balance first
|
||
const balanceResult = await callProvisionAPI(
|
||
'/api/provision/balance',
|
||
'GET',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (balanceResult.error || balanceResult.balance_krw === undefined) {
|
||
return `🚫 잔액 조회 실패: ${balanceResult.error || '알 수 없는 오류'}`;
|
||
}
|
||
|
||
// Get pricing info to check if balance is sufficient
|
||
// For now, we'll proceed with the order and let the API handle balance validation
|
||
|
||
// Generate idempotency key to prevent duplicate orders on network retries
|
||
const idempotencyKey = generateIdempotencyKey(telegramUserId);
|
||
|
||
const orderBody: Record<string, unknown> = {
|
||
user_id: telegramUserId,
|
||
pricing_id,
|
||
label,
|
||
dry_run: false,
|
||
idempotency_key: idempotencyKey,
|
||
};
|
||
|
||
if (image) {
|
||
orderBody.image = image;
|
||
}
|
||
|
||
const result = await callProvisionAPI(
|
||
'/api/provision',
|
||
'POST',
|
||
env,
|
||
orderBody,
|
||
telegramUserId
|
||
);
|
||
|
||
if (result.error || !result.order) {
|
||
// Check if it's a balance error (error_code first, then fallback to text matching)
|
||
if (result.error_code === 'INSUFFICIENT_BALANCE' ||
|
||
(result.error && result.error.includes('balance'))) {
|
||
return `⚠️ 잔액이 부족합니다\n\n• 현재 잔액: ₩${balanceResult.balance_krw.toLocaleString()}\n\n${DEPOSIT_ACCOUNT_INFO}`;
|
||
}
|
||
return `🚫 서버 주문 실패: ${result.error || '알 수 없는 오류'}`;
|
||
}
|
||
|
||
const order = result.order;
|
||
const statusEmoji = getStatusEmoji(order.status);
|
||
const response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${statusEmoji} ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`;
|
||
|
||
return response;
|
||
}
|
||
|
||
case 'start': {
|
||
const { order_id } = args;
|
||
|
||
if (!order_id) {
|
||
return '🚫 서버 시작에는 order_id가 필요합니다.';
|
||
}
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!env) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
// Call the provision API to start the server
|
||
const result = await callProvisionAPI(
|
||
`/api/provision/orders/${order_id}/start`,
|
||
'POST',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (result.error) {
|
||
return `🚫 서버 시작 실패: ${result.error}`;
|
||
}
|
||
|
||
logger.info('서버 시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
|
||
|
||
return `__DIRECT__\n✅ 서버 시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 시작 중...\n\n⏳ 서버가 시작되기까지 1-2분 소요될 수 있습니다.`;
|
||
}
|
||
|
||
case 'stop': {
|
||
const { order_id } = args;
|
||
|
||
if (!order_id) {
|
||
return '🚫 서버 중지에는 order_id가 필요합니다.';
|
||
}
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!env) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
// Call the provision API to stop the server
|
||
const result = await callProvisionAPI(
|
||
`/api/provision/orders/${order_id}/stop`,
|
||
'POST',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (result.error) {
|
||
return `🚫 서버 중지 실패: ${result.error}`;
|
||
}
|
||
|
||
logger.info('서버 중지 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
|
||
|
||
return `__DIRECT__\n✅ 서버 중지 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 중지 중...\n\n⏳ 서버가 중지되기까지 1-2분 소요될 수 있습니다.`;
|
||
}
|
||
|
||
case 'reboot': {
|
||
const { order_id } = args;
|
||
|
||
if (!order_id) {
|
||
return '🚫 서버 재시작에는 order_id가 필요합니다.';
|
||
}
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!env) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
// Call the provision API to reboot the server
|
||
const result = await callProvisionAPI(
|
||
`/api/provision/orders/${order_id}/reboot`,
|
||
'POST',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (result.error) {
|
||
return `🚫 서버 재시작 실패: ${result.error}`;
|
||
}
|
||
|
||
logger.info('서버 재시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
|
||
|
||
return `__DIRECT__\n✅ 서버 재시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 재시작 중...\n\n⏳ 서버가 재시작되기까지 2-3분 소요될 수 있습니다.`;
|
||
}
|
||
|
||
case 'list': {
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!env) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
const result = await callProvisionAPI(
|
||
'/api/provision/orders',
|
||
'GET',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
// API returns orders directly without success field
|
||
if (result.error) {
|
||
return `🚫 서버 목록 조회 실패: ${result.error}`;
|
||
}
|
||
|
||
if (!result.orders) {
|
||
return `🚫 서버 목록 조회 실패: 응답 형식 오류`;
|
||
}
|
||
|
||
return formatServerList(result.orders);
|
||
}
|
||
|
||
case 'info': {
|
||
const { order_id } = args;
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!order_id) {
|
||
return '🚫 서버 상세 조회에는 order_id가 필요합니다.';
|
||
}
|
||
|
||
if (!env) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
const result = await callProvisionAPI(
|
||
`/api/provision/orders/${order_id}`,
|
||
'GET',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (result.error) {
|
||
return `🚫 서버 정보 조회 실패: ${result.error}`;
|
||
}
|
||
|
||
if (!result.order) {
|
||
return `🚫 서버 정보 조회 실패: 해당 서버를 찾을 수 없습니다`;
|
||
}
|
||
|
||
return formatServerInfo(result.order);
|
||
}
|
||
|
||
case 'delete': {
|
||
const { order_id } = args;
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!order_id) {
|
||
return '🚫 서버 해지에는 order_id가 필요합니다.';
|
||
}
|
||
|
||
if (!env || !env.SESSION_KV) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
// First get the order info
|
||
const infoResult = await callProvisionAPI(
|
||
`/api/provision/orders/${order_id}`,
|
||
'GET',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (infoResult.error || !infoResult.order) {
|
||
return `🚫 서버 정보 조회 실패: ${infoResult.error || '해당 서버를 찾을 수 없습니다'}`;
|
||
}
|
||
|
||
const order = infoResult.order;
|
||
|
||
// Store pending deletion in SESSION_KV (5 minutes TTL)
|
||
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
|
||
const deleteSessionData = JSON.stringify({
|
||
orderId: order.id,
|
||
label: order.label,
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
await env.SESSION_KV.put(deleteSessionKey, deleteSessionData, {
|
||
expirationTtl: 300, // 5 minutes
|
||
});
|
||
|
||
logger.info('삭제 대기 세션 생성', { userId: telegramUserId, orderId: order.id });
|
||
|
||
// Return confirmation message (no keyboard)
|
||
let response = '__DIRECT__\n⚠️ 서버 삭제 확인\n\n';
|
||
response += '📋 삭제할 서버 정보\n';
|
||
response += `• 주문번호: #${order.id}\n`;
|
||
response += `• 라벨: ${order.label}\n`;
|
||
if (order.ip_address) {
|
||
response += `• IP: ${order.ip_address}\n`;
|
||
}
|
||
response += '\n';
|
||
response += '🚨 경고: 이 작업은 되돌릴 수 없습니다!\n';
|
||
response += '• 서버의 모든 데이터가 삭제됩니다\n';
|
||
response += '• 백업을 먼저 진행해주세요\n\n';
|
||
response += '정말 삭제하려면 "삭제"라고 입력하세요.\n';
|
||
response += '취소하려면 다른 메시지를 입력하거나 5분간 기다리세요.';
|
||
|
||
return response;
|
||
}
|
||
|
||
case 'images': {
|
||
if (!env) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
const result = await callProvisionAPI(
|
||
'/api/provision/images',
|
||
'GET',
|
||
env
|
||
);
|
||
|
||
if (result.error) {
|
||
return `🚫 이미지 목록 조회 실패: ${result.error}`;
|
||
}
|
||
|
||
if (!result.images) {
|
||
return `🚫 이미지 목록 조회 실패: 응답 형식 오류`;
|
||
}
|
||
|
||
return formatImageList(result.images);
|
||
}
|
||
|
||
case 'rename': {
|
||
const { order_id, new_label } = args;
|
||
|
||
if (!telegramUserId) {
|
||
return '🚫 사용자 인증이 필요합니다.';
|
||
}
|
||
|
||
if (!order_id) {
|
||
return '🚫 이름 변경할 서버 번호를 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
|
||
}
|
||
|
||
if (!new_label) {
|
||
return '🚫 새 서버 이름을 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
|
||
}
|
||
|
||
if (!env || !env.DB) {
|
||
return '🚫 환경 설정이 필요합니다.';
|
||
}
|
||
|
||
// 서버 소유권 확인 및 이름 변경
|
||
const server = await env.DB.prepare(
|
||
`SELECT id, label FROM server_orders WHERE id = ? AND telegram_user_id = ?`
|
||
).bind(order_id, telegramUserId).first<{ id: number; label: string | null }>();
|
||
|
||
if (!server) {
|
||
return '🚫 해당 서버를 찾을 수 없거나 권한이 없습니다.';
|
||
}
|
||
|
||
const oldLabel = server.label || `서버 #${server.id}`;
|
||
|
||
await env.DB.prepare(
|
||
`UPDATE server_orders SET label = ?, updated_at = datetime('now') WHERE id = ?`
|
||
).bind(new_label, order_id).run();
|
||
|
||
logger.info('서버 이름 변경', { orderId: order_id, oldLabel, newLabel: new_label, userId: maskUserId(telegramUserId) });
|
||
|
||
return `✅ 서버 이름이 변경되었습니다.
|
||
|
||
• 이전: ${oldLabel}
|
||
• 변경: ${new_label}`;
|
||
}
|
||
|
||
default:
|
||
return `🚫 알 수 없는 작업: ${action}`;
|
||
}
|
||
}
|
||
|
||
// 서버 삭제 시 환불액 계산 (시간당 요금제)
|
||
function calculateRefund(pricePaid: number, createdAt: string): { refundAmount: number; usedHours: number; hourlyRate: number } {
|
||
const HOURS_PER_MONTH = 720; // 30일 * 24시간
|
||
const hourlyRate = pricePaid / HOURS_PER_MONTH;
|
||
|
||
const createdTime = new Date(createdAt).getTime();
|
||
const now = Date.now();
|
||
const usedMs = now - createdTime;
|
||
const usedHours = Math.ceil(usedMs / (1000 * 60 * 60)); // 올림 처리
|
||
|
||
const usedAmount = Math.ceil(hourlyRate * usedHours);
|
||
const refundAmount = Math.max(0, pricePaid - usedAmount);
|
||
|
||
return { refundAmount, usedHours, hourlyRate };
|
||
}
|
||
|
||
// 실제 서버 삭제 실행 (callback_query에서 호출)
|
||
export async function executeServerDelete(
|
||
orderId: number,
|
||
telegramUserId: string,
|
||
env: Env
|
||
): Promise<{ success: boolean; message: string }> {
|
||
provisionLogger.info('서버 삭제 실행', {
|
||
orderId,
|
||
userId: maskUserId(telegramUserId),
|
||
});
|
||
|
||
// Get order info for refund calculation
|
||
const infoResult = await callProvisionAPI(
|
||
`/api/provision/orders/${orderId}`,
|
||
'GET',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (!infoResult.order) {
|
||
return {
|
||
success: false,
|
||
message: `🚫 서버 정보 조회 실패: ${infoResult.error || '해당 서버를 찾을 수 없습니다'}`,
|
||
};
|
||
}
|
||
|
||
const order = infoResult.order;
|
||
const orderLabel = order.label || `#${orderId}`;
|
||
const pricePaid = order.price_paid || 0;
|
||
|
||
// Delete the order first
|
||
const result = await callProvisionAPI(
|
||
`/api/provision/orders/${orderId}`,
|
||
'DELETE',
|
||
env,
|
||
undefined,
|
||
telegramUserId
|
||
);
|
||
|
||
if (result.error) {
|
||
provisionLogger.error('서버 삭제 실패', new Error(result.error), { orderId });
|
||
return {
|
||
success: false,
|
||
message: `🚫 서버 종료 실패: ${result.error}`,
|
||
};
|
||
}
|
||
|
||
// Update terminated_at in local DB
|
||
try {
|
||
await env.DB.prepare(`
|
||
UPDATE server_orders
|
||
SET terminated_at = datetime('now'), status = 'terminated', updated_at = datetime('now')
|
||
WHERE id = ?
|
||
`).bind(orderId).run();
|
||
} catch (dbError) {
|
||
provisionLogger.error('terminated_at 업데이트 실패 (무시)', dbError as Error, { orderId });
|
||
}
|
||
|
||
// Calculate refund
|
||
let refundMessage = '';
|
||
if (pricePaid > 0 && order.created_at) {
|
||
const { refundAmount, usedHours } = calculateRefund(pricePaid, order.created_at);
|
||
|
||
if (refundAmount > 0) {
|
||
try {
|
||
// Get user from DB
|
||
const userResult = await env.DB.prepare(
|
||
'SELECT id FROM users WHERE telegram_id = ?'
|
||
).bind(telegramUserId).first<{ id: number }>();
|
||
|
||
if (userResult) {
|
||
// Get balance before refund
|
||
const balanceBefore = await env.DB.prepare(
|
||
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
||
).bind(userResult.id).first<{ balance: number }>();
|
||
const beforeBalance = balanceBefore?.balance ?? 0;
|
||
|
||
// Add refund to user_deposits (with version increment for optimistic locking)
|
||
await env.DB.prepare(`
|
||
UPDATE user_deposits
|
||
SET balance = balance + ?, version = version + 1
|
||
WHERE user_id = ?
|
||
`).bind(refundAmount, userResult.id).run();
|
||
|
||
// Record refund transaction
|
||
await env.DB.prepare(`
|
||
INSERT INTO deposit_transactions (user_id, type, amount, status, description, depositor_name, created_at, confirmed_at)
|
||
VALUES (?, 'refund', ?, 'confirmed', ?, '시스템', datetime('now'), datetime('now'))
|
||
`).bind(userResult.id, refundAmount, `서버 해지 환불: ${orderLabel}`).run();
|
||
|
||
const afterBalance = beforeBalance + refundAmount;
|
||
refundMessage = `\n\n💰 환불 정보\n• 환불 전 잔액: ${beforeBalance.toLocaleString()}원\n• 환불 금액: +${refundAmount.toLocaleString()}원\n• 환불 후 잔액: ${afterBalance.toLocaleString()}원`;
|
||
|
||
provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours, beforeBalance, afterBalance });
|
||
}
|
||
} catch (refundError) {
|
||
provisionLogger.error('환불 처리 실패', refundError as Error, { orderId, refundAmount });
|
||
refundMessage = '\n\n⚠️ 환불 처리 중 오류가 발생했습니다. 관리자에게 문의해주세요.';
|
||
}
|
||
} else {
|
||
refundMessage = `\n\n💰 환불 정보\n• 결제 금액: ${pricePaid.toLocaleString()}원\n• 사용 시간: ${usedHours}시간\n• 환불 금액: 0원 (사용 기간 초과)`;
|
||
}
|
||
}
|
||
|
||
// Clear server consultation session (if any)
|
||
try {
|
||
const { 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,
|
||
});
|
||
|
||
// 중복 주문 방지: 진행 중인 주문 확인
|
||
if (env.DB) {
|
||
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
|
||
if (existingOrder) {
|
||
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
|
||
return {
|
||
success: false,
|
||
message: `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
|
||
`• 주문 번호: #${existingOrder.id}\n` +
|
||
`• 라벨: ${existingOrder.label || '없음'}\n` +
|
||
`• 상태: ${statusText}\n\n` +
|
||
`완료 후 다시 시도해주세요.`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Generate idempotency key to prevent duplicate orders on network retries
|
||
const idempotencyKey = generateIdempotencyKey(telegramUserId);
|
||
|
||
// Call provision API
|
||
const result = await callProvisionAPI(
|
||
'/api/provision',
|
||
'POST',
|
||
env,
|
||
{
|
||
user_id: telegramUserId,
|
||
pricing_id: orderData.pricingId,
|
||
label: orderData.label,
|
||
idempotency_key: idempotencyKey,
|
||
},
|
||
telegramUserId
|
||
);
|
||
|
||
if (result.error) {
|
||
provisionLogger.error('서버 주문 실패', new Error(result.error), { orderData });
|
||
|
||
// Check for specific error types (error_code first, then fallback to text matching)
|
||
if (result.error_code === 'INSUFFICIENT_BALANCE' ||
|
||
result.error.includes('INSUFFICIENT_BALANCE') ||
|
||
result.error.includes('잔액')) {
|
||
return {
|
||
success: false,
|
||
message: `💰 잔액이 부족합니다.\n\n입금 후 다시 시도해주세요.\n\n📌 입금 계좌\n하나은행 427-910018-27104\n(주)아이언클래드`,
|
||
};
|
||
}
|
||
|
||
return {
|
||
success: false,
|
||
message: `🚫 서버 신청 실패: ${result.error}`,
|
||
};
|
||
}
|
||
|
||
const order = result.order;
|
||
provisionLogger.info('서버 주문 완료', { orderId: order?.id, plan: orderData.plan });
|
||
|
||
// Clear server consultation session
|
||
try {
|
||
const { 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;
|
||
tech_stack?: string[];
|
||
expected_users?: number;
|
||
use_case?: string;
|
||
traffic_pattern?: string;
|
||
region_preference?: string[];
|
||
budget_limit?: number;
|
||
lang?: string;
|
||
server_id?: string;
|
||
region_code?: string;
|
||
label?: string;
|
||
message?: string;
|
||
pricing_id?: number;
|
||
order_id?: number;
|
||
new_label?: string;
|
||
image?: string;
|
||
},
|
||
env?: Env,
|
||
telegramUserId?: string
|
||
): Promise<string> {
|
||
const { action } = args;
|
||
logger.info('시작', {
|
||
action,
|
||
userId: maskUserId(telegramUserId),
|
||
});
|
||
|
||
try {
|
||
const result = await executeServerAction(action, args, env, telegramUserId);
|
||
logger.info('완료', { result: result?.slice(0, 100) });
|
||
return result;
|
||
} catch (error) {
|
||
logger.error('서버 관리 오류', error as Error, { action });
|
||
return '🚫 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||
}
|
||
}
|