Files
telegram-bot-workers/src/tools/server-tool.ts
kappa 98002473f9 refactor: remove recommendation actions from server-tool
Removed:
- start_consultation, continue_consultation, cancel_consultation, recommend actions
- getRecommendationData(), formatRecommendations()
- CDN cache hit rate estimation (CDN_CACHE_HIT_RATES, estimateCdnCacheHitRate)
- Language detection (detectLanguage)
- callCloudOrchestratorApi() function
- isErrorResult() type guard
- Recommendation-related parameters (tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang, message)
- RecommendResponse type import
- formatTrafficInfo import

Retained:
- order, list, info, delete, images, start, stop, reboot, rename actions
- callProvisionAPI() for provision operations
- Server management core functionality

File size reduced from 1484 to 1057 lines (427 lines removed).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:30:08 +09:00

1058 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Env,
ProvisionResponse,
ProvisionOrder,
OSImage,
} from '../types';
import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger, maskUserId } from '../utils/logger';
import { ERROR_MESSAGES } from '../constants/messages';
const logger = createLogger('server-tool');
const provisionLogger = createLogger('provision');
// Generate idempotency key for order requests
// Format: tg-order-{userId}-{timestamp}-{random}
function generateIdempotencyKey(userId: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 10);
return `tg-order-${userId}-${timestamp}-${random}`;
}
// 진행 중인 주문 확인 (중복 주문 방지)
async function checkExistingOrder(
db: D1Database,
telegramUserId: string
): Promise<{ id: number; status: string; label: string | null } | null> {
const result = await db.prepare(
`SELECT id, status, label FROM server_orders
WHERE telegram_user_id = ?
AND status IN ('pending', 'provisioning')
LIMIT 1`
).bind(telegramUserId).first<{ id: number; status: string; label: string | null }>();
return result || null;
}
export const manageServerTool = {
type: 'function',
function: {
name: 'manage_server',
description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot', 'rename'],
description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, order: 서버 주문, rename: 이름 변경, images: OS 이미지 목록',
},
server_id: {
type: 'string',
description: '서버 ID (레거시, 사용 안 함)',
},
region_code: {
type: 'string',
description: '리전 코드 (레거시, 사용 안 함)',
},
label: {
type: 'string',
description: '서버 라벨 (예: "myapp-prod"). order action에서 필수',
},
pricing_id: {
type: 'number',
description: 'Pricing ID (서버 스펙 ID). order action에서 필수',
},
order_id: {
type: 'number',
description: '주문 번호. info, delete, rename, start, stop, reboot action에서 필수',
},
new_label: {
type: 'string',
description: '새 서버 이름. rename action에서 필수',
},
image: {
type: 'string',
description: 'OS 이미지 키 (예: "ubuntu_22_04"). order action에서 선택 (기본값 사용 가능)',
},
},
required: ['action'],
},
},
};
// Cloud Orchestrator Provision API 호출
async function callProvisionAPI(
endpoint: string,
method: 'GET' | 'POST' | 'DELETE',
env: Env,
body?: Record<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 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
};
}
}
// 서버 상태 이모지
function getStatusEmoji(status: string): string {
switch (status) {
case 'active':
return '🟢';
case 'provisioning':
return '🔄';
case 'stopped':
return '⛔';
case 'deleted':
case 'terminated':
return '🗑️';
case 'failed':
return '❌';
case 'pending':
return '⏳'; // 내부용, UI에 표시 안 함
default:
return '⚪';
}
}
// 서버 상태 텍스트
function getStatusText(status: string): string {
switch (status) {
case 'active':
return '가동 중';
case 'provisioning':
return '생성 중...';
case 'stopped':
return '중지됨';
case 'deleted':
case 'terminated':
return '삭제됨';
case 'failed':
return '실패';
default:
return '알 수 없음';
}
}
// 날짜 포맷팅 (YYYY-MM-DD)
function formatDate(dateString: string): string {
try {
const date = new Date(dateString);
return date.toISOString().split('T')[0];
} catch {
return dateString;
}
}
// 남은 시간 계산 (만료일 기준)
function calculateRemainingTime(expiresAt: string): string {
try {
const expiresTime = new Date(expiresAt).getTime();
const now = Date.now();
const remainingMs = expiresTime - now;
if (remainingMs <= 0) {
return '만료됨';
}
const remainingHours = Math.floor(remainingMs / (1000 * 60 * 60));
const remainingDays = Math.floor(remainingHours / 24);
const hours = remainingHours % 24;
if (remainingDays > 0) {
return `${remainingDays}${hours}시간`;
} else {
return `${hours}시간`;
}
} catch {
return '알 수 없음';
}
}
// 만료일 포맷팅 (YYYY-MM-DD HH:MM)
function formatExpiry(expiresAt: string): string {
try {
const date = new Date(expiresAt);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
} catch {
return expiresAt;
}
}
// 서버 목록 포맷팅
function formatServerList(orders: ProvisionOrder[]): string {
// 'pending' 상태는 내부용 (Queue 대기), UI에 표시하지 않음
// 'provisioning' 또는 'active' 상태만 표시
// 'terminated', 'deleted', 'failed' 상태는 제외
const activeOrders = orders?.filter(order => {
// pending은 표시하지 않음 (내부 Queue 상태)
if (order.status === 'pending') {
return false;
}
// provisioning 또는 active만 표시
if (!['provisioning', 'active'].includes(order.status)) {
return false;
}
// active 상태인데 provider_instance_id가 없으면 제외 (프로비저닝 실패)
if (order.status === 'active' && !order.provider_instance_id) {
return false;
}
return true;
}) || [];
if (activeOrders.length === 0) {
return '🖥️ 등록된 서버가 없습니다.\n\n서버를 추천받으려면 "서버 추천"이라고 말씀해주세요.';
}
let response = '__DIRECT__\n🖥 내 서버 목록\n\n';
activeOrders.forEach((order) => {
const statusEmoji = getStatusEmoji(order.status);
const statusText = getStatusText(order.status);
const label = order.label || '(라벨 없음)';
response += `#${order.id} ${statusEmoji} ${label}\n`;
if (order.ip_address) {
response += ` • IP: ${order.ip_address}\n`;
}
response += ` • 상태: ${statusText}\n`;
response += ` • 생성일: ${formatDate(order.created_at)}\n`;
// 만료일 표시 (있을 경우)
if (order.expires_at) {
const remainingTime = calculateRemainingTime(order.expires_at);
response += ` • 만료일: ${formatExpiry(order.expires_at)}\n`;
response += ` • 남은 시간: ${remainingTime}\n`;
}
response += '\n';
});
response += '💡 서버 관리: "N번 시작/중지" 또는 "#N 재시작"';
return response.trim();
}
// 서버 상세 정보 포맷팅
function formatServerInfo(order: ProvisionOrder): string {
const statusEmoji = getStatusEmoji(order.status);
const statusText = getStatusText(order.status);
let response = '__DIRECT__\n🖥 서버 상세 정보\n\n';
response += '📋 기본 정보\n';
response += `• 주문번호: #${order.id}\n`;
response += `• 라벨: ${order.label}\n`;
response += `• 상태: ${statusEmoji} ${statusText}\n\n`;
if (order.status === 'active' && order.ip_address) {
response += '🔗 접속 정보\n';
response += `• IP: ${order.ip_address}\n`;
if (order.image) {
response += `• OS: ${order.image.replace(/_/g, ' ')}\n`;
}
if (order.root_password) {
response += `• Password: ${order.root_password}\n\n`;
response += '⚠️ 비밀번호는 안전한 곳에 보관하세요.\n';
}
} else if (order.status === 'provisioning') {
response += '⏳ 서버 생성 중입니다.\n잠시 후 다시 조회해주세요.\n';
}
return response.trim();
}
// OS 이미지 목록 포맷팅
function formatImageList(images: OSImage[]): string {
if (!images || images.length === 0) {
return '📀 사용 가능한 OS 이미지가 없습니다.';
}
let response = '__DIRECT__\n📀 사용 가능한 OS 이미지\n\n';
images.forEach((image) => {
// is_default can be boolean or number (1/0)
const isDefault = image.is_default === true || image.is_default === 1;
const marker = isDefault ? '✓ ' : ' ';
const defaultText = isDefault ? ' (기본)' : '';
response += `${marker}${image.name}${defaultText}\n`;
});
return response.trim();
}
// 입금 계좌 정보
const DEPOSIT_ACCOUNT_INFO = `💳 입금 계좌
하나은행 427-910018-27104
예금주: (주)아이언클래드
입금 후 "입금 신고"로 알려주세요.`;
// 서버 작업 직접 실행
export async function executeServerAction(
action: string,
args: {
server_id?: string;
region_code?: string;
label?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;
image?: string;
},
env?: Env,
telegramUserId?: string
): Promise<string> {
logger.info('작업 시작', {
action,
userId: maskUserId(telegramUserId),
args: JSON.stringify(args).slice(0, 200),
});
switch (action) {
case 'order': {
const { pricing_id, label, image } = args;
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!pricing_id || !label) {
return '🚫 서버 주문에는 pricing_id와 label이 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
// 중복 주문 방지: 진행 중인 주문 확인
if (env.DB) {
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
if (existingOrder) {
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
return `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
`• 주문 번호: #${existingOrder.id}\n` +
`• 라벨: ${existingOrder.label || '없음'}\n` +
`• 상태: ${statusText}\n\n` +
`완료 후 다시 시도해주세요.`;
}
}
// Check balance first
const balanceResult = await callProvisionAPI(
'/api/provision/balance',
'GET',
env,
undefined,
telegramUserId
);
if (balanceResult.error || balanceResult.balance_krw === undefined) {
return `🚫 잔액 조회 실패: ${balanceResult.error || '알 수 없는 오류'}`;
}
// Get pricing info to check if balance is sufficient
// For now, we'll proceed with the order and let the API handle balance validation
// Generate idempotency key to prevent duplicate orders on network retries
const idempotencyKey = generateIdempotencyKey(telegramUserId);
const orderBody: Record<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 { ServerSessionManager } = await import('../utils/session-manager');
const { getSessionConfig } = await import('../constants/agent-config');
const sessionManager = new ServerSessionManager(getSessionConfig('server'));
await sessionManager.delete(env.DB, telegramUserId);
} catch (error) {
provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error);
}
provisionLogger.info('서버 삭제 완료', { orderId });
return {
success: true,
message: `✅ 서버가 종료되었습니다.\n\n• 주문번호: #${orderId}\n• 라벨: ${orderLabel}${refundMessage}`,
};
}
// 실제 서버 주문 실행 (텍스트 확인에서 호출)
export async function executeServerOrder(
orderData: {
userId: string;
index: number;
plan: string;
pricingId: number;
region: string;
label: string;
},
telegramUserId: string,
env: Env
): Promise<{ success: boolean; message: string }> {
provisionLogger.info('서버 주문 실행', {
userId: maskUserId(telegramUserId),
plan: orderData.plan,
pricingId: orderData.pricingId,
});
// 중복 주문 방지: 진행 중인 주문 확인
if (env.DB) {
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
if (existingOrder) {
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
return {
success: false,
message: `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
`• 주문 번호: #${existingOrder.id}\n` +
`• 라벨: ${existingOrder.label || '없음'}\n` +
`• 상태: ${statusText}\n\n` +
`완료 후 다시 시도해주세요.`,
};
}
}
// Generate idempotency key to prevent duplicate orders on network retries
const idempotencyKey = generateIdempotencyKey(telegramUserId);
// Call provision API
const result = await callProvisionAPI(
'/api/provision',
'POST',
env,
{
user_id: telegramUserId,
pricing_id: orderData.pricingId,
label: orderData.label,
idempotency_key: idempotencyKey,
},
telegramUserId
);
if (result.error) {
provisionLogger.error('서버 주문 실패', new Error(result.error), { orderData });
// Check for specific error types (error_code first, then fallback to text matching)
if (result.error_code === 'INSUFFICIENT_BALANCE' ||
result.error.includes('INSUFFICIENT_BALANCE') ||
result.error.includes('잔액')) {
return {
success: false,
message: `💰 잔액이 부족합니다.\n\n입금 후 다시 시도해주세요.\n\n📌 입금 계좌\n하나은행 427-910018-27104\n(주)아이언클래드`,
};
}
return {
success: false,
message: `🚫 서버 신청 실패: ${result.error}`,
};
}
const order = result.order;
provisionLogger.info('서버 주문 완료', { orderId: order?.id, plan: orderData.plan });
// Clear server consultation session
try {
const { ServerSessionManager } = await import('../utils/session-manager');
const { getSessionConfig } = await import('../constants/agent-config');
const sessionManager = new ServerSessionManager(getSessionConfig('server'));
await sessionManager.delete(env.DB, telegramUserId);
} catch (error) {
provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error);
}
// Build success message
let successMessage = `✅ 서버 신청이 완료되었습니다!\n\n`;
successMessage += `📋 주문 정보\n`;
successMessage += `• 주문번호: #${order?.id || 'N/A'}\n`;
successMessage += `• 플랜: ${orderData.plan}\n`;
successMessage += `• 라벨: ${orderData.label}\n`;
if (order?.ip_address) {
successMessage += `\n🌐 서버 정보\n`;
successMessage += `• IP 주소: ${order.ip_address}\n`;
successMessage += `• 비밀번호: 보안을 위해 별도로 조회하세요\n`;
} else {
successMessage += `\n⏳ 서버가 프로비저닝 중입니다.\n`;
successMessage += `잠시 후 "내 서버" 명령으로 상태를 확인해주세요.`;
}
return {
success: true,
message: successMessage,
};
}
export async function executeManageServer(
args: {
action: string;
server_id?: string;
region_code?: string;
label?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;
image?: string;
},
env?: Env,
telegramUserId?: string
): Promise<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 '🚫 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}