feat: add server provisioning system with Queue
- Add server-provision.ts for async server creation - Add SERVER_PROVISION_QUEUE with DLQ for reliability - Add cron job for auto-cleanup of pending orders (5min) - Add server delete confirmation with inline keyboard - Update types for server orders, images, and provisioning - Add server tables to schema (server_orders, server_instances) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
59
schema.sql
59
schema.sql
@@ -86,6 +86,59 @@ CREATE TABLE IF NOT EXISTS deposit_transactions (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 서버 상담 세션 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS server_sessions (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('gathering', 'recommending', 'selecting', 'ordering', 'completed')),
|
||||||
|
collected_info TEXT,
|
||||||
|
last_recommendation TEXT,
|
||||||
|
messages TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 서버 주문 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS server_orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
telegram_user_id TEXT NOT NULL,
|
||||||
|
spec_id INTEGER NOT NULL,
|
||||||
|
region TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
price_paid INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'provisioning', 'active', 'failed', 'cancelled', 'terminated')),
|
||||||
|
provider TEXT NOT NULL CHECK(provider IN ('linode', 'vultr', 'anvil')),
|
||||||
|
provider_instance_id TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
root_password TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
provisioned_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
idempotency_key TEXT UNIQUE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 사용자 서버 테이블 (활성 서버 관리)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
order_id INTEGER NOT NULL UNIQUE,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
instance_id TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
region TEXT,
|
||||||
|
spec_label TEXT,
|
||||||
|
monthly_price INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'stopped', 'terminated')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (order_id) REFERENCES server_orders(id)
|
||||||
|
);
|
||||||
|
|
||||||
-- 인덱스
|
-- 인덱스
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_domains_user ON user_domains(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_domains_user ON user_domains(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain);
|
CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain);
|
||||||
@@ -100,3 +153,9 @@ CREATE INDEX IF NOT EXISTS idx_buffer_chat ON message_buffer(user_id, chat_id);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id);
|
CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_summary_latest ON summaries(user_id, chat_id, generation DESC);
|
CREATE INDEX IF NOT EXISTS idx_summary_latest ON summaries(user_id, chat_id, generation DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id);
|
CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_sessions_expires ON server_sessions(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_orders_user ON server_orders(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_orders_status ON server_orders(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_orders_idempotency ON server_orders(idempotency_key) WHERE idempotency_key IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_servers_status ON user_servers(status);
|
||||||
|
|||||||
174
src/index.ts
174
src/index.ts
@@ -1,4 +1,4 @@
|
|||||||
import { Env, EmailMessage } from './types';
|
import { Env, EmailMessage, ProvisionMessage, MessageBatch } from './types';
|
||||||
import { sendMessage, setWebhook, getWebhookInfo } from './telegram';
|
import { sendMessage, setWebhook, getWebhookInfo } from './telegram';
|
||||||
import { handleWebhook } from './routes/webhook';
|
import { handleWebhook } from './routes/webhook';
|
||||||
import { handleApiRequest } from './routes/api';
|
import { handleApiRequest } from './routes/api';
|
||||||
@@ -6,7 +6,11 @@ import { handleHealthCheck } from './routes/health';
|
|||||||
import { parseBankSMS } from './services/bank-sms-parser';
|
import { parseBankSMS } from './services/bank-sms-parser';
|
||||||
import { matchPendingDeposit } from './services/deposit-matcher';
|
import { matchPendingDeposit } from './services/deposit-matcher';
|
||||||
import { reconcileDeposits, formatReconciliationReport } from './utils/reconciliation';
|
import { reconcileDeposits, formatReconciliationReport } from './utils/reconciliation';
|
||||||
|
import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
|
||||||
import { timingSafeEqual } from './security';
|
import { timingSafeEqual } from './security';
|
||||||
|
import { createLogger } from './utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('worker');
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// HTTP 요청 핸들러
|
// HTTP 요청 핸들러
|
||||||
@@ -103,17 +107,17 @@ Documentation: https://github.com/your-repo
|
|||||||
|
|
||||||
// 이메일 주소 마스킹
|
// 이메일 주소 마스킹
|
||||||
const maskedFrom = message.from.replace(/@.+/, '@****');
|
const maskedFrom = message.from.replace(/@.+/, '@****');
|
||||||
console.log('[Email] 수신:', maskedFrom, 'Size:', message.rawSize);
|
logger.info('이메일 수신', { from: maskedFrom, size: message.rawSize });
|
||||||
|
|
||||||
// SMS 내용 파싱
|
// SMS 내용 파싱
|
||||||
const notification = await parseBankSMS(rawEmail, env);
|
const notification = await parseBankSMS(rawEmail, env);
|
||||||
if (!notification) {
|
if (!notification) {
|
||||||
console.log('[Email] 은행 SMS 파싱 실패');
|
logger.info('은행 SMS 파싱 실패');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파싱 결과 마스킹 로깅
|
// 파싱 결과 마스킹 로깅
|
||||||
console.log('[Email] 파싱 결과:', {
|
logger.info('SMS 파싱 결과', {
|
||||||
bankName: notification.bankName,
|
bankName: notification.bankName,
|
||||||
depositorName: notification.depositorName
|
depositorName: notification.depositorName
|
||||||
? notification.depositorName.slice(0, 2) + '***'
|
? notification.depositorName.slice(0, 2) + '***'
|
||||||
@@ -140,16 +144,16 @@ Documentation: https://github.com/your-repo
|
|||||||
).run();
|
).run();
|
||||||
|
|
||||||
const notificationId = insertResult.meta.last_row_id;
|
const notificationId = insertResult.meta.last_row_id;
|
||||||
console.log('[Email] 알림 저장 완료, ID:', notificationId);
|
logger.info('알림 저장 완료', { notificationId });
|
||||||
|
|
||||||
// 자동 매칭 시도
|
// 자동 매칭 시도
|
||||||
const matched = await matchPendingDeposit(env.DB, notificationId, notification);
|
const matched = await matchPendingDeposit(env.DB, notificationId, notification);
|
||||||
|
|
||||||
// 매칭 결과 로깅 (민감 정보 마스킹)
|
// 매칭 결과 로깅 (민감 정보 마스킹)
|
||||||
if (matched) {
|
if (matched) {
|
||||||
console.log('[Email] 자동 매칭 성공: 거래 ID', matched.transactionId);
|
logger.info('자동 매칭 성공', { transactionId: matched.transactionId });
|
||||||
} else {
|
} else {
|
||||||
console.log('[Email] 매칭되는 거래 없음');
|
logger.info('매칭되는 거래 없음');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 매칭 성공 시 사용자에게 알림
|
// 매칭 성공 시 사용자에게 알림
|
||||||
@@ -192,13 +196,120 @@ Documentation: https://github.com/your-repo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Email] 처리 오류:', error);
|
logger.error('이메일 처리 오류', error as Error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cron Trigger: 만료된 입금 대기 자동 취소 (24시간)
|
// Cron Triggers: 입금 대기 자동 취소 (24시간) + 서버 주문 자동 삭제 (5분)
|
||||||
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
|
async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
|
||||||
console.log('[Cron] 만료된 입금 대기 정리 시작');
|
const cronSchedule = event.cron;
|
||||||
|
logger.info('Cron 작업 시작', { schedule: cronSchedule });
|
||||||
|
|
||||||
|
// 매 5분: pending 서버 주문 자동 삭제
|
||||||
|
if (cronSchedule === '*/5 * * * *') {
|
||||||
|
await cleanupStalePendingServerOrders(env);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매일 자정 (KST): 입금 만료 + 정합성 검증
|
||||||
|
if (cronSchedule === '0 15 * * *') {
|
||||||
|
await cleanupExpiredDepositTransactions(env);
|
||||||
|
await reconcileDepositBalances(env);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('알 수 없는 Cron 스케줄', { schedule: cronSchedule });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Queue Consumer 핸들러
|
||||||
|
async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
|
||||||
|
// Queue 이름으로 구분
|
||||||
|
const queueName = batch.queue;
|
||||||
|
|
||||||
|
if (queueName === 'server-provision-queue') {
|
||||||
|
await handleProvisionQueue(batch, env);
|
||||||
|
} else if (queueName === 'provision-dlq') {
|
||||||
|
await handleProvisionDLQ(batch, env);
|
||||||
|
} else {
|
||||||
|
logger.warn('알 수 없는 Queue', { queue: queueName });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cron Job Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5분 이상 pending 상태인 서버 주문 자동 삭제
|
||||||
|
* 실행 주기: 매 5분 (every 5 minutes)
|
||||||
|
*/
|
||||||
|
async function cleanupStalePendingServerOrders(env: Env): Promise<void> {
|
||||||
|
logger.info('서버 주문 정리 시작 (5분 경과)');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 5분 이상 된 pending 서버 주문 조회
|
||||||
|
const staleOrders = await env.DB.prepare(
|
||||||
|
`SELECT so.id, so.label, so.price_paid, u.telegram_id
|
||||||
|
FROM server_orders so
|
||||||
|
JOIN users u ON so.user_id = u.id
|
||||||
|
WHERE so.status = 'pending'
|
||||||
|
AND datetime(so.created_at) < datetime('now', '-5 minutes')
|
||||||
|
LIMIT 50`
|
||||||
|
).all<{
|
||||||
|
id: number;
|
||||||
|
label: string | null;
|
||||||
|
price_paid: number;
|
||||||
|
telegram_id: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
if (!staleOrders.results?.length) {
|
||||||
|
logger.info('삭제할 서버 주문 없음');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('방치된 서버 주문 발견', { count: staleOrders.results.length });
|
||||||
|
|
||||||
|
// 서버 주문 삭제
|
||||||
|
const orderIds = staleOrders.results.map(order => order.id);
|
||||||
|
await env.DB.prepare(
|
||||||
|
`DELETE FROM server_orders WHERE id IN (${orderIds.map(() => '?').join(',')})`
|
||||||
|
).bind(...orderIds).run();
|
||||||
|
|
||||||
|
logger.info('서버 주문 삭제 완료', { count: orderIds.length });
|
||||||
|
|
||||||
|
// 사용자 알림 병렬 처리 (개별 실패 무시)
|
||||||
|
const notificationPromises = staleOrders.results.map(order =>
|
||||||
|
sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
parseInt(order.telegram_id),
|
||||||
|
`❌ <b>서버 주문 자동 취소</b>\n\n` +
|
||||||
|
`주문 #${order.id}이 처리되지 않아 자동 취소되었습니다.\n` +
|
||||||
|
`• 서버명: ${order.label || '(미지정)'}\n` +
|
||||||
|
`• 결제 금액: ${order.price_paid.toLocaleString()}원\n\n` +
|
||||||
|
`다시 시도해주세요.`
|
||||||
|
).catch(err => {
|
||||||
|
logger.error('알림 전송 실패', err as Error, {
|
||||||
|
orderId: order.id,
|
||||||
|
userId: order.telegram_id
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(notificationPromises);
|
||||||
|
logger.info('서버 주문 정리 완료', { count: staleOrders.results.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('서버 주문 정리 오류', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 24시간 이상 pending 상태인 입금 거래 자동 취소
|
||||||
|
* 실행 주기: 매일 자정 KST (0 15 * * *)
|
||||||
|
*/
|
||||||
|
async function cleanupExpiredDepositTransactions(env: Env): Promise<void> {
|
||||||
|
logger.info('만료된 입금 대기 정리 시작');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 24시간 이상 된 pending 거래 조회
|
// 24시간 이상 된 pending 거래 조회
|
||||||
@@ -218,11 +329,11 @@ Documentation: https://github.com/your-repo
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
if (!expiredTxs.results?.length) {
|
if (!expiredTxs.results?.length) {
|
||||||
console.log('[Cron] 만료된 거래 없음');
|
logger.info('만료된 거래 없음');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Cron] 만료된 거래 ${expiredTxs.results.length}건 발견`);
|
logger.info('만료된 거래 발견', { count: expiredTxs.results.length });
|
||||||
|
|
||||||
// 단일 UPDATE 쿼리로 일괄 처리
|
// 단일 UPDATE 쿼리로 일괄 처리
|
||||||
const ids = expiredTxs.results.map(tx => tx.id);
|
const ids = expiredTxs.results.map(tx => tx.id);
|
||||||
@@ -232,7 +343,7 @@ Documentation: https://github.com/your-repo
|
|||||||
WHERE id IN (${ids.map(() => '?').join(',')})`
|
WHERE id IN (${ids.map(() => '?').join(',')})`
|
||||||
).bind(...ids).run();
|
).bind(...ids).run();
|
||||||
|
|
||||||
console.log(`[Cron] UPDATE 완료: ${ids.length}건`);
|
logger.info('UPDATE 완료', { count: ids.length });
|
||||||
|
|
||||||
// 알림 병렬 처리 (개별 실패가 전체를 중단시키지 않도록 .catch() 추가)
|
// 알림 병렬 처리 (개별 실패가 전체를 중단시키지 않도록 .catch() 추가)
|
||||||
const notificationPromises = expiredTxs.results.map(tx =>
|
const notificationPromises = expiredTxs.results.map(tx =>
|
||||||
@@ -245,19 +356,28 @@ Documentation: https://github.com/your-repo
|
|||||||
`• 입금자: ${tx.depositor_name}\n\n` +
|
`• 입금자: ${tx.depositor_name}\n\n` +
|
||||||
`실제 입금하셨다면 다시 신고해주세요.`
|
`실제 입금하셨다면 다시 신고해주세요.`
|
||||||
).catch(err => {
|
).catch(err => {
|
||||||
console.error(`[Cron] 알림 전송 실패 (거래 #${tx.id}, 사용자 ${tx.telegram_id}):`, err);
|
logger.error('알림 전송 실패', err as Error, {
|
||||||
return null; // 실패한 알림은 null로 처리
|
transactionId: tx.id,
|
||||||
|
userId: tx.telegram_id
|
||||||
|
});
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(notificationPromises);
|
await Promise.all(notificationPromises);
|
||||||
console.log(`[Cron] ${expiredTxs.results.length}건 만료 처리 완료 (알림 전송 완료)`);
|
logger.info('만료 처리 완료', { count: expiredTxs.results.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Cron] 오류:', error);
|
logger.error('Cron 작업 오류', error as Error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예치금 정합성 검증 (Reconciliation)
|
/**
|
||||||
console.log('[Cron] 예치금 정합성 검증 시작');
|
* 예치금 정합성 검증 (Reconciliation)
|
||||||
|
* 실행 주기: 매일 자정 KST (0 15 * * *)
|
||||||
|
*/
|
||||||
|
async function reconcileDepositBalances(env: Env): Promise<void> {
|
||||||
|
logger.info('예치금 정합성 검증 시작');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const report = await reconcileDeposits(env.DB);
|
const report = await reconcileDeposits(env.DB);
|
||||||
|
|
||||||
@@ -267,17 +387,19 @@ Documentation: https://github.com/your-repo
|
|||||||
if (adminId) {
|
if (adminId) {
|
||||||
const message = formatReconciliationReport(report);
|
const message = formatReconciliationReport(report);
|
||||||
await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => {
|
await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => {
|
||||||
console.error('[Cron] 정합성 검증 알림 전송 실패:', err);
|
logger.error('정합성 검증 알림 전송 실패', err as Error);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn('[Cron] DEPOSIT_ADMIN_ID 미설정 - 알림 전송 불가');
|
logger.warn('DEPOSIT_ADMIN_ID 미설정 - 알림 전송 불가');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Cron] 정합성 검증 완료: ${report.totalUsers}명 검증, ${report.inconsistencies}건 불일치`);
|
logger.info('정합성 검증 완료', {
|
||||||
|
totalUsers: report.totalUsers,
|
||||||
|
inconsistencies: report.inconsistencies
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Cron] 정합성 검증 실패:', error);
|
logger.error('정합성 검증 실패', error as Error);
|
||||||
// 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행
|
// 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export async function handleCallbackQuery(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SECURITY NOTE: Price from callback_data is range-validated here (0-10M)
|
||||||
|
// but real-time price verification happens in executeDomainRegister()
|
||||||
|
// which fetches current price from Namecheap API before charging
|
||||||
|
|
||||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' });
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' });
|
||||||
await editMessageText(
|
await editMessageText(
|
||||||
env.BOT_TOKEN,
|
env.BOT_TOKEN,
|
||||||
@@ -127,9 +131,18 @@ ${result.error}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = parts[1];
|
const callbackUserId = parts[1];
|
||||||
const index = parseInt(parts[2], 10);
|
const index = parseInt(parts[2], 10);
|
||||||
|
|
||||||
|
// SECURITY: Verify callback userId matches the actual user
|
||||||
|
if (callbackUserId !== telegramUserId) {
|
||||||
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, {
|
||||||
|
text: '⚠️ 권한이 없습니다.',
|
||||||
|
show_alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isNaN(index) || index < 0 || index > 2) {
|
if (isNaN(index) || index < 0 || index > 2) {
|
||||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
|
||||||
return;
|
return;
|
||||||
@@ -146,7 +159,7 @@ ${result.error}
|
|||||||
// 세션 조회
|
// 세션 조회
|
||||||
const { getServerSession, deleteServerSession } = await import('../../server-agent');
|
const { getServerSession, deleteServerSession } = await import('../../server-agent');
|
||||||
|
|
||||||
if (!env.SESSION_KV) {
|
if (!env.DB) {
|
||||||
await editMessageText(
|
await editMessageText(
|
||||||
env.BOT_TOKEN,
|
env.BOT_TOKEN,
|
||||||
chatId,
|
chatId,
|
||||||
@@ -156,7 +169,8 @@ ${result.error}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getServerSession(env.SESSION_KV, userId);
|
// Use verified telegramUserId instead of callback userId
|
||||||
|
const session = await getServerSession(env.DB, telegramUserId);
|
||||||
|
|
||||||
if (!session || !session.lastRecommendation) {
|
if (!session || !session.lastRecommendation) {
|
||||||
await editMessageText(
|
await editMessageText(
|
||||||
@@ -177,33 +191,74 @@ ${result.error}
|
|||||||
messageId,
|
messageId,
|
||||||
'❌ 선택한 서버를 찾을 수 없습니다.'
|
'❌ 선택한 서버를 찾을 수 없습니다.'
|
||||||
);
|
);
|
||||||
await deleteServerSession(env.SESSION_KV, userId);
|
await deleteServerSession(env.DB, telegramUserId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 주문 처리 (현재는 준비 중)
|
// 잔액 확인
|
||||||
const { executeServerAction } = await import('../../tools/server-tool');
|
const deposit = await env.DB.prepare(
|
||||||
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(user.id).first<{ balance: number }>();
|
||||||
|
|
||||||
const result = await executeServerAction(
|
const price = selected.price?.monthly_krw || 0;
|
||||||
'order',
|
|
||||||
{
|
if (!deposit || deposit.balance < price) {
|
||||||
server_id: selected.plan_name, // 임시
|
await editMessageText(
|
||||||
region_code: selected.region.code,
|
env.BOT_TOKEN, chatId, messageId,
|
||||||
label: `${session.collectedInfo.useCase || 'server'}-1`
|
`❌ 잔액이 부족합니다.
|
||||||
},
|
|
||||||
env,
|
• 서버 가격: ${price.toLocaleString()}원/월
|
||||||
userId
|
• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원
|
||||||
|
• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원
|
||||||
|
|
||||||
|
잔액을 충전 후 다시 시도해주세요.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue 확인
|
||||||
|
if (!env.SERVER_PROVISION_QUEUE) {
|
||||||
|
await editMessageText(
|
||||||
|
env.BOT_TOKEN, chatId, messageId,
|
||||||
|
'❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 주문 생성 (DB INSERT)
|
||||||
|
const { createServerOrder, sendProvisionMessage } = await import('../../server-provision');
|
||||||
|
|
||||||
|
const orderId = await createServerOrder(
|
||||||
|
env.DB,
|
||||||
|
user.id,
|
||||||
|
telegramUserId,
|
||||||
|
selected.pricing_id,
|
||||||
|
selected.region.code,
|
||||||
|
'anvil',
|
||||||
|
price,
|
||||||
|
`${selected.plan_name} - ${session.collectedInfo?.useCase || 'server'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Queue에 메시지 전송
|
||||||
|
await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, user.id, telegramUserId);
|
||||||
|
|
||||||
|
// 즉시 응답
|
||||||
await editMessageText(
|
await editMessageText(
|
||||||
env.BOT_TOKEN,
|
env.BOT_TOKEN,
|
||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
`📋 ${selected.plan_name} 신청\n\n${result}`
|
`📋 <b>서버 주문 접수 완료!</b> (주문 #${orderId})
|
||||||
|
|
||||||
|
• 서버: ${selected.plan_name}
|
||||||
|
• 리전: ${selected.region.name} (${selected.region.code})
|
||||||
|
• 가격: ${price.toLocaleString()}원/월
|
||||||
|
|
||||||
|
⏳ 서버를 생성하고 있습니다... (1-2분 소요)
|
||||||
|
완료되면 메시지로 알려드릴게요.`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 세션 삭제
|
// 세션 삭제
|
||||||
await deleteServerSession(env.SESSION_KV, userId);
|
await deleteServerSession(env.DB, telegramUserId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +270,16 @@ ${result.error}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = parts[1];
|
const callbackUserId = parts[1];
|
||||||
|
|
||||||
|
// SECURITY: Verify callback userId matches the actual user
|
||||||
|
if (callbackUserId !== telegramUserId) {
|
||||||
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, {
|
||||||
|
text: '⚠️ 권한이 없습니다.',
|
||||||
|
show_alert: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
||||||
await editMessageText(
|
await editMessageText(
|
||||||
@@ -228,13 +292,15 @@ ${result.error}
|
|||||||
// 세션 삭제
|
// 세션 삭제
|
||||||
const { deleteServerSession } = await import('../../server-agent');
|
const { deleteServerSession } = await import('../../server-agent');
|
||||||
|
|
||||||
if (env.SESSION_KV) {
|
if (env.DB) {
|
||||||
await deleteServerSession(env.SESSION_KV, userId);
|
await deleteServerSession(env.DB, telegramUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: server_delete callback handler removed - now using text-based confirmation
|
||||||
|
|
||||||
// 알 수 없는 callback data
|
// 알 수 없는 callback data
|
||||||
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,167 @@ export async function handleMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Session 데이터 미리 읽기 (KV 중복 호출 방지)
|
||||||
|
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
|
||||||
|
const orderSessionKey = `server_order_confirm:${telegramUserId}`;
|
||||||
|
|
||||||
|
const [deleteSessionData, orderSessionData] = await Promise.all([
|
||||||
|
env.SESSION_KV.get(deleteSessionKey),
|
||||||
|
env.SESSION_KV.get(orderSessionKey),
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 4. 명령어 처리
|
// 5. 서버 삭제 확인 처리 (텍스트 기반)
|
||||||
|
if (text.trim() === '삭제') {
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await sendMessage(env.BOT_TOKEN, chatId, result.message);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleMessage] 서버 삭제 처리 오류:', error);
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
chatId,
|
||||||
|
'🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 서버 삭제 취소 처리 (다른 메시지 입력 시)
|
||||||
|
if (deleteSessionData && text.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 (!text.startsWith('/')) {
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
chatId,
|
||||||
|
`⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleMessage] 삭제 세션 취소 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 서버 신청 확인 처리 (텍스트 기반) - Queue 기반
|
||||||
|
if (text.trim() === '신청') {
|
||||||
|
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);
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
chatId,
|
||||||
|
'❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = session.lastRecommendation.recommendations[orderData.index];
|
||||||
|
if (!selected) {
|
||||||
|
await env.SESSION_KV.delete(orderSessionKey);
|
||||||
|
await deleteServerSession(env.DB, telegramUserId);
|
||||||
|
await sendMessage(env.BOT_TOKEN, chatId, '❌ 선택한 서버를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
chatId,
|
||||||
|
`❌ 잔액이 부족합니다.\n\n` +
|
||||||
|
`• 서버 가격: ${price.toLocaleString()}원/월\n` +
|
||||||
|
`• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n` +
|
||||||
|
`• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원\n\n` +
|
||||||
|
`잔액을 충전 후 다시 시도해주세요.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Queue 확인
|
||||||
|
if (!env.SERVER_PROVISION_QUEUE) {
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
chatId,
|
||||||
|
'❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. 즉시 응답
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
chatId,
|
||||||
|
`📋 <b>서버 주문 접수 완료!</b> (주문 #${orderId})\n\n` +
|
||||||
|
`• 서버: ${selected.plan_name}\n` +
|
||||||
|
`• 리전: ${selected.region.name} (${selected.region.code})\n` +
|
||||||
|
`• 가격: ${price.toLocaleString()}원/월\n\n` +
|
||||||
|
`⏳ 서버를 생성하고 있습니다... (1-2분 소요)\n` +
|
||||||
|
`완료되면 메시지로 알려드릴게요.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleMessage] 서버 신청 처리 오류:', error);
|
||||||
|
await sendMessage(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
chatId,
|
||||||
|
'🚫 서버 신청 중 오류가 발생했습니다. 다시 시도해주세요.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 명령어 처리
|
||||||
if (text.startsWith('/')) {
|
if (text.startsWith('/')) {
|
||||||
const [command, ...argParts] = text.split(' ');
|
const [command, ...argParts] = text.split(' ');
|
||||||
const args = argParts.join(' ');
|
const args = argParts.join(' ');
|
||||||
@@ -74,7 +233,7 @@ export async function handleMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 일반 대화 처리 (ConversationService 위임)
|
// 9. 일반 대화 처리 (ConversationService 위임)
|
||||||
const result = await conversationService.processUserMessage(
|
const result = await conversationService.processUserMessage(
|
||||||
userId,
|
userId,
|
||||||
chatIdStr,
|
chatIdStr,
|
||||||
@@ -87,7 +246,7 @@ export async function handleMessage(
|
|||||||
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 응답 전송 (키보드 포함 여부 확인)
|
// 10. 응답 전송 (키보드 포함 여부 확인)
|
||||||
if (result.keyboardData) {
|
if (result.keyboardData) {
|
||||||
console.log('[Webhook] Keyboard data received:', result.keyboardData.type);
|
console.log('[Webhook] Keyboard data received:', result.keyboardData.type);
|
||||||
if (result.keyboardData.type === 'domain_register') {
|
if (result.keyboardData.type === 'domain_register') {
|
||||||
|
|||||||
573
src/server-provision.ts
Normal file
573
src/server-provision.ts
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
import type { Env, ProvisionMessage, MessageBatch, ServerOrder, ProvisionResponse } from './types';
|
||||||
|
import { createLogger } from './utils/logger';
|
||||||
|
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
|
||||||
|
import { sendMessage } from './telegram';
|
||||||
|
|
||||||
|
const logger = createLogger('server-provision');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에서 서버 주문 정보 조회
|
||||||
|
*/
|
||||||
|
async function getServerOrder(
|
||||||
|
db: D1Database,
|
||||||
|
orderId: number
|
||||||
|
): Promise<ServerOrder | null> {
|
||||||
|
const order = await db.prepare(
|
||||||
|
`SELECT * FROM server_orders WHERE id = ?`
|
||||||
|
).bind(orderId).first<ServerOrder>();
|
||||||
|
|
||||||
|
return order || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에 서버 주문 상태 업데이트
|
||||||
|
* @param db - D1 Database
|
||||||
|
* @param orderId - 주문 ID
|
||||||
|
* @param status - 업데이트할 상태
|
||||||
|
* @param updates - 추가 업데이트 필드 (선택)
|
||||||
|
*/
|
||||||
|
async function updateOrderStatus(
|
||||||
|
db: D1Database,
|
||||||
|
orderId: number,
|
||||||
|
status: ServerOrder['status'],
|
||||||
|
updates?: {
|
||||||
|
provider_instance_id?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
root_password?: string;
|
||||||
|
error_message?: string;
|
||||||
|
provisioned_at?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const fields: string[] = ['status = ?', 'updated_at = CURRENT_TIMESTAMP'];
|
||||||
|
const values: (string | number)[] = [status];
|
||||||
|
|
||||||
|
if (updates) {
|
||||||
|
if (updates.provider_instance_id) {
|
||||||
|
fields.push('provider_instance_id = ?');
|
||||||
|
values.push(updates.provider_instance_id);
|
||||||
|
}
|
||||||
|
if (updates.ip_address) {
|
||||||
|
fields.push('ip_address = ?');
|
||||||
|
values.push(updates.ip_address);
|
||||||
|
}
|
||||||
|
if (updates.root_password) {
|
||||||
|
fields.push('root_password = ?');
|
||||||
|
values.push(updates.root_password);
|
||||||
|
}
|
||||||
|
if (updates.error_message) {
|
||||||
|
fields.push('error_message = ?');
|
||||||
|
values.push(updates.error_message);
|
||||||
|
}
|
||||||
|
if (updates.provisioned_at) {
|
||||||
|
fields.push('provisioned_at = ?');
|
||||||
|
values.push(updates.provisioned_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.prepare(
|
||||||
|
`UPDATE server_orders SET ${fields.join(', ')} WHERE id = ?`
|
||||||
|
).bind(...values, orderId).run();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`Failed to update order status: ${orderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('서버 주문 상태 업데이트', {
|
||||||
|
orderId,
|
||||||
|
status,
|
||||||
|
updates
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 주문 삭제 (실패한 주문 정리용)
|
||||||
|
*/
|
||||||
|
async function deleteServerOrder(db: D1Database, orderId: number): Promise<void> {
|
||||||
|
const result = await db.prepare(
|
||||||
|
'DELETE FROM server_orders WHERE id = ?'
|
||||||
|
).bind(orderId).run();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`Failed to delete order: ${orderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('서버 주문 삭제', { orderId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잔액 차감 (Optimistic Locking 적용)
|
||||||
|
*/
|
||||||
|
async function deductBalance(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number,
|
||||||
|
amount: number,
|
||||||
|
reason: string
|
||||||
|
): Promise<void> {
|
||||||
|
await executeWithOptimisticLock(db, async () => {
|
||||||
|
// 현재 잔액 및 version 조회
|
||||||
|
const current = await db.prepare(
|
||||||
|
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
|
||||||
|
).bind(userId).first<{ balance: number; version: number }>();
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw new Error('User deposit account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.balance < amount) {
|
||||||
|
throw new Error('Insufficient balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 잔액 차감 및 version 증가 (Optimistic Locking)
|
||||||
|
const updateResult = await db.prepare(
|
||||||
|
`UPDATE user_deposits
|
||||||
|
SET balance = balance - ?,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND version = ?`
|
||||||
|
).bind(amount, userId, current.version).run();
|
||||||
|
|
||||||
|
if (updateResult.meta.changes === 0) {
|
||||||
|
throw new OptimisticLockError('Version mismatch during balance deduction');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 거래 내역 기록
|
||||||
|
const txResult = await db.prepare(
|
||||||
|
`INSERT INTO deposit_transactions
|
||||||
|
(user_id, type, amount, status, description, confirmed_at)
|
||||||
|
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
|
||||||
|
).bind(userId, amount, reason).run();
|
||||||
|
|
||||||
|
if (!txResult.success) {
|
||||||
|
throw new Error('Failed to record withdrawal transaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('잔액 차감 완료', {
|
||||||
|
userId,
|
||||||
|
amount,
|
||||||
|
reason,
|
||||||
|
newBalance: current.balance - amount
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자에게 Telegram 알림 전송
|
||||||
|
*/
|
||||||
|
async function notifyUser(
|
||||||
|
botToken: string,
|
||||||
|
telegramUserId: string,
|
||||||
|
message: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendMessage(botToken, parseInt(telegramUserId), message);
|
||||||
|
logger.info('사용자 알림 전송 완료', { telegramUserId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('사용자 알림 전송 실패', error as Error, { telegramUserId });
|
||||||
|
// 알림 실패는 치명적이지 않으므로 에러를 전파하지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자에게 Telegram 알림 전송
|
||||||
|
*/
|
||||||
|
async function notifyAdmin(
|
||||||
|
botToken: string,
|
||||||
|
adminId: string | undefined,
|
||||||
|
message: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!adminId) {
|
||||||
|
logger.warn('DEPOSIT_ADMIN_ID 미설정 - 관리자 알림 생략');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessage(botToken, parseInt(adminId), message);
|
||||||
|
logger.info('관리자 알림 전송 완료', { adminId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('관리자 알림 전송 실패', error as Error, { adminId });
|
||||||
|
// 알림 실패는 치명적이지 않으므로 에러를 전파하지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloud Orchestrator API 호출 (Service Binding)
|
||||||
|
*/
|
||||||
|
async function callCloudOrchestrator(
|
||||||
|
orchestrator: Fetcher | undefined,
|
||||||
|
apiKey: string | undefined,
|
||||||
|
orderId: number,
|
||||||
|
userId: string,
|
||||||
|
pricingId: number,
|
||||||
|
label: string,
|
||||||
|
image?: string
|
||||||
|
): Promise<ProvisionResponse> {
|
||||||
|
if (!orchestrator) {
|
||||||
|
throw new Error('CLOUD_ORCHESTRATOR Service Binding not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
user_id: userId,
|
||||||
|
pricing_id: pricingId,
|
||||||
|
label,
|
||||||
|
idempotency_key: `telegram-bot-order-${orderId}`,
|
||||||
|
...(image && { image })
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 키 추가 (필수)
|
||||||
|
if (apiKey) {
|
||||||
|
headers['X-API-Key'] = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Cloud Orchestrator API 호출', { ...body, hasApiKey: !!apiKey });
|
||||||
|
|
||||||
|
const response = await orchestrator.fetch('https://internal/api/provision', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Cloud Orchestrator API failed: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as ProvisionResponse;
|
||||||
|
|
||||||
|
logger.info('Cloud Orchestrator API 응답', data);
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Provisioning failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에 서버 주문 생성
|
||||||
|
* @returns order_id
|
||||||
|
*/
|
||||||
|
export async function createServerOrder(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number,
|
||||||
|
telegramUserId: string,
|
||||||
|
specId: number,
|
||||||
|
region: string,
|
||||||
|
provider: 'anvil' | 'linode' | 'vultr',
|
||||||
|
pricePaid: number,
|
||||||
|
label?: string
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const result = await db.prepare(
|
||||||
|
`INSERT INTO server_orders
|
||||||
|
(user_id, telegram_user_id, spec_id, region, provider, price_paid, label, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')`
|
||||||
|
).bind(userId, telegramUserId, specId, region, provider, pricePaid, label || null).run();
|
||||||
|
|
||||||
|
if (!result.success || !result.meta.last_row_id) {
|
||||||
|
throw new Error('Failed to create server order');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = result.meta.last_row_id as number;
|
||||||
|
|
||||||
|
// Set idempotency_key for Cloud Orchestrator deduplication
|
||||||
|
const idempotencyKey = `telegram-bot-order-${orderId}`;
|
||||||
|
await db.prepare(
|
||||||
|
`UPDATE server_orders SET idempotency_key = ? WHERE id = ?`
|
||||||
|
).bind(idempotencyKey, orderId).run();
|
||||||
|
|
||||||
|
logger.info('서버 주문 생성', {
|
||||||
|
orderId,
|
||||||
|
userId,
|
||||||
|
telegramUserId,
|
||||||
|
specId,
|
||||||
|
region,
|
||||||
|
provider,
|
||||||
|
pricePaid,
|
||||||
|
idempotencyKey
|
||||||
|
});
|
||||||
|
|
||||||
|
return orderId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('서버 주문 생성 실패', error as Error, {
|
||||||
|
userId,
|
||||||
|
telegramUserId,
|
||||||
|
specId,
|
||||||
|
region
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue에 프로비저닝 메시지 전송
|
||||||
|
*/
|
||||||
|
export async function sendProvisionMessage(
|
||||||
|
queue: Queue<ProvisionMessage>,
|
||||||
|
orderId: number,
|
||||||
|
userId: number,
|
||||||
|
telegramUserId: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await queue.send({
|
||||||
|
order_id: orderId,
|
||||||
|
user_id: userId,
|
||||||
|
telegram_user_id: telegramUserId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retry_count: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Queue 메시지 전송 완료', {
|
||||||
|
orderId,
|
||||||
|
userId,
|
||||||
|
telegramUserId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Queue 메시지 전송 실패', error as Error, {
|
||||||
|
orderId,
|
||||||
|
userId,
|
||||||
|
telegramUserId
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 Queue 핸들러 - 서버 프로비저닝 요청 처리
|
||||||
|
*/
|
||||||
|
export async function handleProvisionQueue(
|
||||||
|
batch: MessageBatch<ProvisionMessage>,
|
||||||
|
env: Env
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info('Provision Queue 처리 시작', {
|
||||||
|
messageCount: batch.messages.length
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const message of batch.messages) {
|
||||||
|
try {
|
||||||
|
const { order_id, user_id, telegram_user_id } = message.body;
|
||||||
|
|
||||||
|
logger.info('프로비저닝 처리 중', {
|
||||||
|
orderId: order_id,
|
||||||
|
userId: user_id,
|
||||||
|
telegramUserId: telegram_user_id,
|
||||||
|
attempts: message.attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 주문 정보 조회
|
||||||
|
const order = await getServerOrder(env.DB, order_id);
|
||||||
|
if (!order) {
|
||||||
|
throw new Error(`Order not found: ${order_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 처리된 주문인지 확인 (pending, provisioning만 처리)
|
||||||
|
if (order.status !== 'pending' && order.status !== 'provisioning') {
|
||||||
|
logger.warn('이미 처리된 주문', {
|
||||||
|
orderId: order_id,
|
||||||
|
currentStatus: order.status
|
||||||
|
});
|
||||||
|
message.ack();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 상태를 provisioning으로 업데이트 (pending일 때만)
|
||||||
|
if (order.status === 'pending') {
|
||||||
|
await updateOrderStatus(env.DB, order_id, 'provisioning');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cloud Orchestrator API 호출
|
||||||
|
try {
|
||||||
|
const provisionResult = await callCloudOrchestrator(
|
||||||
|
env.CLOUD_ORCHESTRATOR,
|
||||||
|
env.PROVISION_API_KEY,
|
||||||
|
order_id,
|
||||||
|
telegram_user_id,
|
||||||
|
parseInt(order.spec_id),
|
||||||
|
order.label || `server-${order_id}`,
|
||||||
|
undefined // image는 향후 추가 예정
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!provisionResult.order) {
|
||||||
|
throw new Error('Provisioning response missing order data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 잔액 차감 (성공 후에만 실행)
|
||||||
|
try {
|
||||||
|
await deductBalance(
|
||||||
|
env.DB,
|
||||||
|
user_id,
|
||||||
|
order.price_paid,
|
||||||
|
`서버 주문 #${order_id} - ${order.label || order.spec_id}`
|
||||||
|
);
|
||||||
|
} catch (balanceError) {
|
||||||
|
// 잔액 차감 실패 시 - 서버는 생성됐지만 결제 실패
|
||||||
|
// 이 경우 관리자 알림 필요 (서버는 수동 삭제 필요)
|
||||||
|
logger.error('잔액 차감 실패 (서버는 생성됨)', balanceError as Error, {
|
||||||
|
orderId: order_id,
|
||||||
|
userId: user_id
|
||||||
|
});
|
||||||
|
|
||||||
|
// 주문 상태는 active로 변경하되, 결제 실패 표시
|
||||||
|
await updateOrderStatus(env.DB, order_id, 'active', {
|
||||||
|
provider_instance_id: provisionResult.order.provider_instance_id || undefined,
|
||||||
|
ip_address: provisionResult.order.ip_address || undefined,
|
||||||
|
// root_password는 Cloud Orchestrator가 이미 DB에 저장함 - 덮어쓰지 않음
|
||||||
|
provisioned_at: new Date().toISOString(),
|
||||||
|
error_message: '결제 실패 - 관리자 확인 필요'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 관리자 알림
|
||||||
|
await notifyAdmin(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
env.DEPOSIT_ADMIN_ID,
|
||||||
|
`🚨 결제 실패 알림\n\n주문 #${order_id}\n서버는 생성됐으나 잔액 차감 실패\n사용자: ${telegram_user_id}\n금액: ${order.price_paid.toLocaleString()}원\n\n수동 처리 필요`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 사용자 알림
|
||||||
|
await notifyUser(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
telegram_user_id,
|
||||||
|
`⚠️ 서버 생성 완료, 결제 처리 중 문제 발생\n\nIP: ${provisionResult.order.ip_address || 'N/A'}\n\n관리자가 확인 후 연락드리겠습니다.`
|
||||||
|
);
|
||||||
|
|
||||||
|
message.ack();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 성공 시 DB 업데이트
|
||||||
|
// Note: root_password는 Cloud Orchestrator가 생성하여 DB에 저장함
|
||||||
|
// API 응답의 root_password는 마스킹된 값이므로 업데이트하지 않음
|
||||||
|
await updateOrderStatus(env.DB, order_id, 'active', {
|
||||||
|
provider_instance_id: provisionResult.order.provider_instance_id || undefined,
|
||||||
|
ip_address: provisionResult.order.ip_address || undefined,
|
||||||
|
// root_password는 Cloud Orchestrator가 이미 DB에 저장함 - 덮어쓰지 않음
|
||||||
|
provisioned_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. 사용자 알림은 Cloud Orchestrator에서 처리
|
||||||
|
// (실제 IP와 비밀번호가 할당된 후 전송)
|
||||||
|
logger.info('프로비저닝 요청 완료 - Cloud Orchestrator에서 알림 처리', {
|
||||||
|
orderId: order_id,
|
||||||
|
providerInstanceId: provisionResult.order.provider_instance_id
|
||||||
|
});
|
||||||
|
|
||||||
|
message.ack();
|
||||||
|
logger.info('프로비저닝 완료', { orderId: order_id });
|
||||||
|
} catch (error) {
|
||||||
|
// Cloud Orchestrator API 실패
|
||||||
|
logger.error('Cloud Orchestrator API 호출 실패', error as Error, {
|
||||||
|
orderId: order_id,
|
||||||
|
attempts: message.attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
// 재시도 로직
|
||||||
|
if (message.attempts < 3) {
|
||||||
|
message.retry();
|
||||||
|
logger.info('프로비저닝 재시도 예약', {
|
||||||
|
orderId: order_id,
|
||||||
|
nextAttempt: message.attempts + 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 최대 재시도 초과 - 주문 삭제 (잔액 차감 안 됐으므로)
|
||||||
|
await deleteServerOrder(env.DB, order_id);
|
||||||
|
|
||||||
|
await notifyUser(
|
||||||
|
env.BOT_TOKEN,
|
||||||
|
telegram_user_id,
|
||||||
|
`❌ 서버 프로비저닝 실패\n\n사유: ${error instanceof Error ? error.message : 'API 호출 실패'}\n\n잔액 차감은 이루어지지 않았습니다.\n다시 시도해주세요.`
|
||||||
|
);
|
||||||
|
|
||||||
|
message.ack();
|
||||||
|
logger.warn('최대 재시도 초과 - 주문 삭제', {
|
||||||
|
orderId: order_id,
|
||||||
|
attempts: message.attempts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('프로비저닝 처리 중 예외 발생', error as Error, {
|
||||||
|
orderId: message.body.order_id,
|
||||||
|
attempts: message.attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
// 예상치 못한 에러 - 재시도
|
||||||
|
if (message.attempts < 3) {
|
||||||
|
message.retry();
|
||||||
|
} else {
|
||||||
|
// DLQ로 이동
|
||||||
|
message.ack();
|
||||||
|
logger.warn('최대 재시도 초과 - DLQ로 이동', {
|
||||||
|
orderId: message.body.order_id,
|
||||||
|
attempts: message.attempts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dead Letter Queue 핸들러 - 프로비저닝 실패 건 처리
|
||||||
|
*/
|
||||||
|
export async function handleProvisionDLQ(
|
||||||
|
batch: MessageBatch<ProvisionMessage>,
|
||||||
|
env: Env
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info('DLQ 처리 시작', {
|
||||||
|
messageCount: batch.messages.length
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const message of batch.messages) {
|
||||||
|
try {
|
||||||
|
const { order_id, user_id, telegram_user_id } = message.body;
|
||||||
|
|
||||||
|
logger.error('DLQ 메시지 처리', new Error('Provisioning failed permanently'), {
|
||||||
|
orderId: order_id,
|
||||||
|
userId: user_id,
|
||||||
|
telegramUserId: telegram_user_id,
|
||||||
|
attempts: message.attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. DB에 실패 상태 기록
|
||||||
|
await updateOrderStatus(env.DB, order_id, 'failed', {
|
||||||
|
error_message: 'Provisioning failed after maximum retries (moved to DLQ)'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 관리자에게 알림
|
||||||
|
const adminMessage = `🚨 서버 프로비저닝 영구 실패 (DLQ)
|
||||||
|
|
||||||
|
주문 ID: ${order_id}
|
||||||
|
사용자 ID: ${user_id}
|
||||||
|
Telegram ID: ${telegram_user_id}
|
||||||
|
재시도 횟수: ${message.attempts}
|
||||||
|
|
||||||
|
수동 개입 필요 - 환불 및 사용자 안내 필요`;
|
||||||
|
|
||||||
|
await notifyAdmin(env.BOT_TOKEN, env.DEPOSIT_ADMIN_ID, adminMessage);
|
||||||
|
|
||||||
|
// 3. 사용자에게 안내
|
||||||
|
const userMessage = `❌ 서버 프로비저닝 실패
|
||||||
|
|
||||||
|
주문 #${order_id}
|
||||||
|
|
||||||
|
죄송합니다. 서버 프로비저닝 중 문제가 발생했습니다.
|
||||||
|
잔액 차감은 이루어지지 않았습니다.
|
||||||
|
|
||||||
|
문의사항이 있으시면 관리자에게 연락해주세요.`;
|
||||||
|
|
||||||
|
await notifyUser(env.BOT_TOKEN, telegram_user_id, userMessage);
|
||||||
|
|
||||||
|
message.ack();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DLQ 처리 중 오류', error as Error, {
|
||||||
|
orderId: message.body.order_id
|
||||||
|
});
|
||||||
|
|
||||||
|
// DLQ 처리 실패는 심각한 문제이므로 수동 개입 필요
|
||||||
|
// 메시지는 ack()하여 무한 루프 방지
|
||||||
|
message.ack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ const RedditSearchArgsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ManageServerArgsSchema = z.object({
|
const ManageServerArgsSchema = z.object({
|
||||||
action: z.enum(['recommend', 'order', 'start', 'stop', 'delete', 'list',
|
action: z.enum(['recommend', 'order', 'start', 'stop', 'delete', 'list', 'info', 'images',
|
||||||
'start_consultation', 'continue_consultation', 'cancel_consultation']),
|
'start_consultation', 'continue_consultation', 'cancel_consultation']),
|
||||||
tech_stack: z.array(z.string().min(1).max(100)).max(20).optional(),
|
tech_stack: z.array(z.string().min(1).max(100)).max(20).optional(),
|
||||||
expected_users: z.number().int().positive().optional(),
|
expected_users: z.number().int().positive().optional(),
|
||||||
@@ -77,6 +77,9 @@ const ManageServerArgsSchema = z.object({
|
|||||||
region_code: z.string().min(1).max(50).optional(),
|
region_code: z.string().min(1).max(50).optional(),
|
||||||
label: z.string().min(1).max(100).optional(),
|
label: z.string().min(1).max(100).optional(),
|
||||||
message: z.string().min(1).max(500).optional(), // For continue_consultation
|
message: z.string().min(1).max(500).optional(), // For continue_consultation
|
||||||
|
pricing_id: z.number().int().positive().optional(), // For order
|
||||||
|
order_id: z.number().int().positive().optional(), // For info, delete
|
||||||
|
image: z.string().min(1).max(50).optional(), // For order (OS image)
|
||||||
});
|
});
|
||||||
|
|
||||||
const ManageTroubleshootArgsSchema = z.object({
|
const ManageTroubleshootArgsSchema = z.object({
|
||||||
@@ -152,6 +155,45 @@ export function selectToolsForMessage(message: string): typeof tools {
|
|||||||
return selectedTools;
|
return selectedTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic validated executor helper
|
||||||
|
function createValidatedExecutor<T extends z.ZodType>(
|
||||||
|
schema: T,
|
||||||
|
executor: (data: z.infer<T>, env?: Env, userId?: string, db?: D1Database) => Promise<string>,
|
||||||
|
toolName: string
|
||||||
|
) {
|
||||||
|
return async (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
env?: Env,
|
||||||
|
userId?: string,
|
||||||
|
db?: D1Database
|
||||||
|
): Promise<string> => {
|
||||||
|
const result = schema.safeParse(args);
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(`Invalid ${toolName} args`, new Error(result.error.message), { args });
|
||||||
|
return `❌ 잘못된 입력: ${result.error.issues.map(e => e.message).join(', ')}`;
|
||||||
|
}
|
||||||
|
return executor(result.data, env, userId, db);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool executor registry
|
||||||
|
const toolExecutors: Record<
|
||||||
|
string,
|
||||||
|
(args: Record<string, unknown>, env?: Env, userId?: string, db?: D1Database) => Promise<string>
|
||||||
|
> = {
|
||||||
|
get_weather: createValidatedExecutor(GetWeatherArgsSchema, executeWeather, 'weather'),
|
||||||
|
search_web: createValidatedExecutor(SearchWebArgsSchema, executeSearchWeb, 'search'),
|
||||||
|
lookup_docs: createValidatedExecutor(LookupDocsArgsSchema, executeLookupDocs, 'lookup_docs'),
|
||||||
|
get_current_time: createValidatedExecutor(GetCurrentTimeArgsSchema, executeGetCurrentTime, 'time'),
|
||||||
|
calculate: createValidatedExecutor(CalculateArgsSchema, executeCalculate, 'calculate'),
|
||||||
|
manage_domain: createValidatedExecutor(ManageDomainArgsSchema, executeManageDomain, 'domain'),
|
||||||
|
suggest_domains: createValidatedExecutor(SuggestDomainsArgsSchema, executeSuggestDomains, 'suggest_domains'),
|
||||||
|
manage_deposit: createValidatedExecutor(ManageDepositArgsSchema, executeManageDeposit, 'deposit'),
|
||||||
|
manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'),
|
||||||
|
search_reddit: createValidatedExecutor(RedditSearchArgsSchema, executeRedditSearch, 'reddit'),
|
||||||
|
manage_troubleshoot: createValidatedExecutor(ManageTroubleshootArgsSchema, executeManageTroubleshoot, 'troubleshoot'),
|
||||||
|
};
|
||||||
|
|
||||||
// Tool execution dispatcher with validation
|
// Tool execution dispatcher with validation
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -161,109 +203,11 @@ export async function executeTool(
|
|||||||
db?: D1Database
|
db?: D1Database
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
const executor = toolExecutors[name];
|
||||||
case 'get_weather': {
|
if (executor) {
|
||||||
const result = GetWeatherArgsSchema.safeParse(args);
|
return executor(args, env, telegramUserId, db);
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid weather args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
}
|
||||||
return executeWeather(result.data, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'search_web': {
|
|
||||||
const result = SearchWebArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid search args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeSearchWeb(result.data, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'lookup_docs': {
|
|
||||||
const result = LookupDocsArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid lookup_docs args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeLookupDocs(result.data, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'get_current_time': {
|
|
||||||
const result = GetCurrentTimeArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid time args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeGetCurrentTime(result.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'calculate': {
|
|
||||||
const result = CalculateArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid calculate args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeCalculate(result.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'manage_domain': {
|
|
||||||
const result = ManageDomainArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid domain args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeManageDomain(result.data, env, telegramUserId, db);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'suggest_domains': {
|
|
||||||
const result = SuggestDomainsArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid suggest_domains args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeSuggestDomains(result.data, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'manage_deposit': {
|
|
||||||
const result = ManageDepositArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid deposit args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeManageDeposit(result.data, env, telegramUserId, db);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'manage_server': {
|
|
||||||
const result = ManageServerArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid server args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeManageServer(result.data, env, telegramUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'search_reddit': {
|
|
||||||
const result = RedditSearchArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid reddit args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeRedditSearch(result.data, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'manage_troubleshoot': {
|
|
||||||
const result = ManageTroubleshootArgsSchema.safeParse(args);
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error('Invalid troubleshoot args', new Error(result.error.message), { args });
|
|
||||||
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
|
||||||
}
|
|
||||||
return executeManageTroubleshoot(result.data, env, telegramUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return `알 수 없는 도구: ${name}`;
|
return `알 수 없는 도구: ${name}`;
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Tool execution error', error as Error, { name, args });
|
logger.error('Tool execution error', error as Error, { name, args });
|
||||||
return `⚠️ 도구 실행 중 오류가 발생했습니다.`;
|
return `⚠️ 도구 실행 중 오류가 발생했습니다.`;
|
||||||
|
|||||||
225
src/types.ts
225
src/types.ts
@@ -14,6 +14,7 @@ export interface Env {
|
|||||||
DEPOSIT_ADMIN_ID?: string;
|
DEPOSIT_ADMIN_ID?: string;
|
||||||
BRAVE_API_KEY?: string;
|
BRAVE_API_KEY?: string;
|
||||||
DEPOSIT_API_SECRET?: string;
|
DEPOSIT_API_SECRET?: string;
|
||||||
|
PROVISION_API_KEY?: string;
|
||||||
OPENAI_API_BASE?: string;
|
OPENAI_API_BASE?: string;
|
||||||
NAMECHEAP_API_URL?: string;
|
NAMECHEAP_API_URL?: string;
|
||||||
WHOIS_API_URL?: string;
|
WHOIS_API_URL?: string;
|
||||||
@@ -25,6 +26,7 @@ export interface Env {
|
|||||||
CLOUD_ORCHESTRATOR?: Fetcher; // Service Binding
|
CLOUD_ORCHESTRATOR?: Fetcher; // Service Binding
|
||||||
RATE_LIMIT_KV: KVNamespace;
|
RATE_LIMIT_KV: KVNamespace;
|
||||||
SESSION_KV: KVNamespace;
|
SESSION_KV: KVNamespace;
|
||||||
|
SERVER_PROVISION_QUEUE: Queue<ProvisionMessage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntentAnalysis {
|
export interface IntentAnalysis {
|
||||||
@@ -212,6 +214,8 @@ export interface ManageServerArgs {
|
|||||||
| "stop"
|
| "stop"
|
||||||
| "delete"
|
| "delete"
|
||||||
| "list"
|
| "list"
|
||||||
|
| "info"
|
||||||
|
| "images"
|
||||||
| "start_consultation"
|
| "start_consultation"
|
||||||
| "continue_consultation"
|
| "continue_consultation"
|
||||||
| "cancel_consultation";
|
| "cancel_consultation";
|
||||||
@@ -226,6 +230,9 @@ export interface ManageServerArgs {
|
|||||||
region_code?: string;
|
region_code?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
message?: string; // For continue_consultation
|
message?: string; // For continue_consultation
|
||||||
|
pricing_id?: number; // For order
|
||||||
|
order_id?: number; // For info, delete
|
||||||
|
image?: string; // For order (OS image key)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManageMemoryArgs {
|
export interface ManageMemoryArgs {
|
||||||
@@ -251,6 +258,7 @@ export interface ServerSession {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastRecommendation?: {
|
lastRecommendation?: {
|
||||||
recommendations: Array<{
|
recommendations: Array<{
|
||||||
|
pricing_id: number; // cloud-instances-db.anvil_pricing.id
|
||||||
plan_name: string;
|
plan_name: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
specs: { vcpu: number; ram_gb: number; storage_gb: number };
|
specs: { vcpu: number; ram_gb: number; storage_gb: number };
|
||||||
@@ -263,6 +271,7 @@ export interface ServerSession {
|
|||||||
cdn_cache_hit_rate?: number; // CDN 히트율 (0.0-1.0)
|
cdn_cache_hit_rate?: number; // CDN 히트율 (0.0-1.0)
|
||||||
overage_tb?: number;
|
overage_tb?: number;
|
||||||
overage_cost_krw?: number;
|
overage_cost_krw?: number;
|
||||||
|
currency?: string; // 통화 단위 (KRW, USD 등)
|
||||||
};
|
};
|
||||||
score: number;
|
score: number;
|
||||||
max_users: number;
|
max_users: number;
|
||||||
@@ -392,9 +401,21 @@ export interface BraveSearchResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI API 응답 타입
|
// OpenAI API 응답 타입
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenAIMessage {
|
export interface OpenAIMessage {
|
||||||
role: string;
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
content: string;
|
content: string | null;
|
||||||
|
tool_calls?: ToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
|
name?: string; // For tool responses
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIChoice {
|
export interface OpenAIChoice {
|
||||||
@@ -438,7 +459,14 @@ export interface ServerOrderKeyboardData {
|
|||||||
plan: string; // 플랜 이름
|
plan: string; // 플랜 이름
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData;
|
export interface ServerDeleteKeyboardData {
|
||||||
|
type: "server_delete";
|
||||||
|
orderId: number;
|
||||||
|
label: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData | ServerDeleteKeyboardData;
|
||||||
|
|
||||||
// Bandwidth Info (shared by server-agent and server-tool)
|
// Bandwidth Info (shared by server-agent and server-tool)
|
||||||
export interface BandwidthInfo {
|
export interface BandwidthInfo {
|
||||||
@@ -454,6 +482,84 @@ export interface BandwidthInfo {
|
|||||||
cdn_cache_hit_rate?: number;
|
cdn_cache_hit_rate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-related types (Cloud Orchestrator API responses)
|
||||||
|
export interface ServerSpec {
|
||||||
|
id: number;
|
||||||
|
provider_name: string;
|
||||||
|
instance_id: string;
|
||||||
|
instance_name: string;
|
||||||
|
vcpu: number;
|
||||||
|
memory_mb: number;
|
||||||
|
memory_gb: number;
|
||||||
|
storage_gb: number;
|
||||||
|
network_speed_gbps: number | null;
|
||||||
|
instance_family: string;
|
||||||
|
gpu_count: number;
|
||||||
|
gpu_type: string | null;
|
||||||
|
monthly_price: number;
|
||||||
|
region_name: string;
|
||||||
|
region_code: string;
|
||||||
|
country_code: string;
|
||||||
|
transfer_tb: number;
|
||||||
|
transfer_price_per_gb: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BenchmarkItem {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
score: number;
|
||||||
|
percentile: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableRegion {
|
||||||
|
region_name: string;
|
||||||
|
region_code: string;
|
||||||
|
monthly_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRecommendation {
|
||||||
|
server: ServerSpec;
|
||||||
|
score: number;
|
||||||
|
analysis: {
|
||||||
|
tech_fit: string;
|
||||||
|
capacity: string;
|
||||||
|
cost_efficiency: string;
|
||||||
|
scalability: string;
|
||||||
|
};
|
||||||
|
estimated_capacity: {
|
||||||
|
max_concurrent_users: number;
|
||||||
|
requests_per_second: number;
|
||||||
|
};
|
||||||
|
bandwidth_info?: BandwidthInfo;
|
||||||
|
benchmark_reference?: {
|
||||||
|
processor_name: string;
|
||||||
|
benchmarks: BenchmarkItem[];
|
||||||
|
};
|
||||||
|
vps_benchmark_reference?: {
|
||||||
|
plan_name: string;
|
||||||
|
geekbench_single: number;
|
||||||
|
geekbench_multi: number;
|
||||||
|
monthly_price_usd: number;
|
||||||
|
performance_per_dollar: number;
|
||||||
|
};
|
||||||
|
available_regions?: AvailableRegion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecommendResponse {
|
||||||
|
recommendations: ServerRecommendation[];
|
||||||
|
infrastructure_tips?: string[];
|
||||||
|
bandwidth_estimate?: {
|
||||||
|
monthly_tb: number;
|
||||||
|
monthly_gb: number;
|
||||||
|
daily_gb: number;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
total_candidates?: number;
|
||||||
|
cached?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Workers AI Types (from worker-configuration.d.ts)
|
// Workers AI Types (from worker-configuration.d.ts)
|
||||||
export type WorkersAIModel =
|
export type WorkersAIModel =
|
||||||
| "@cf/meta/llama-3.1-8b-instruct"
|
| "@cf/meta/llama-3.1-8b-instruct"
|
||||||
@@ -481,3 +587,116 @@ export interface WorkersAITextGenerationOutput {
|
|||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cloud Orchestrator Provision API Types
|
||||||
|
export interface ProvisionOrder {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
status: 'provisioning' | 'active' | 'stopped' | 'deleted' | 'failed';
|
||||||
|
price_paid: number;
|
||||||
|
label: string;
|
||||||
|
ip_address?: string;
|
||||||
|
root_password?: string;
|
||||||
|
provider_instance_id?: string;
|
||||||
|
image?: string;
|
||||||
|
created_at: string;
|
||||||
|
provisioned_at?: string;
|
||||||
|
deleted_at?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OSImage {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
family?: string;
|
||||||
|
is_default: boolean | number; // API returns boolean, but some DBs return 0/1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisionResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
order?: ProvisionOrder;
|
||||||
|
orders?: ProvisionOrder[];
|
||||||
|
images?: OSImage[];
|
||||||
|
balance_krw?: number;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Provision Queue Message
|
||||||
|
export interface ProvisionMessage {
|
||||||
|
order_id: number;
|
||||||
|
user_id: number;
|
||||||
|
telegram_user_id: string;
|
||||||
|
timestamp: number;
|
||||||
|
retry_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Order (Local Database)
|
||||||
|
export interface ServerOrder {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
telegram_user_id: string;
|
||||||
|
spec_id: string;
|
||||||
|
region: string;
|
||||||
|
label?: string;
|
||||||
|
price_paid: number;
|
||||||
|
status: 'pending' | 'provisioning' | 'active' | 'terminated';
|
||||||
|
provider: 'linode' | 'vultr' | 'anvil';
|
||||||
|
instance_id?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
root_password?: string;
|
||||||
|
error_message?: string;
|
||||||
|
provisioned_at?: string;
|
||||||
|
terminated_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Server
|
||||||
|
export interface UserServer {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
order_id: number;
|
||||||
|
provider: string;
|
||||||
|
instance_id: string;
|
||||||
|
label?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
region?: string;
|
||||||
|
spec_label?: string;
|
||||||
|
monthly_price?: number;
|
||||||
|
status: 'active' | 'stopped' | 'terminated';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provisioning Result
|
||||||
|
export interface ProvisionResult {
|
||||||
|
success: boolean;
|
||||||
|
order_id: number;
|
||||||
|
instance_id?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
root_password?: string;
|
||||||
|
plan_label?: string;
|
||||||
|
region?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloudflare Queue MessageBatch 타입
|
||||||
|
export interface MessageBatch<T> {
|
||||||
|
readonly queue: string;
|
||||||
|
readonly messages: Message<T>[];
|
||||||
|
ack(idOrIds: string | string[]): void;
|
||||||
|
retry(idOrIds: string | string[]): void;
|
||||||
|
retryAll(): void;
|
||||||
|
ackAll(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message<T> {
|
||||||
|
readonly id: string;
|
||||||
|
readonly body: T;
|
||||||
|
readonly timestamp: Date;
|
||||||
|
readonly attempts: number;
|
||||||
|
ack(): void;
|
||||||
|
retry(): void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ preview_id = "302ad556567447cbac49c20bded4eb7e"
|
|||||||
binding = "CLOUD_ORCHESTRATOR"
|
binding = "CLOUD_ORCHESTRATOR"
|
||||||
service = "cloud-orchestrator"
|
service = "cloud-orchestrator"
|
||||||
|
|
||||||
# Cron Trigger: 매일 자정(KST) 실행 - 24시간 경과된 입금 대기 자동 취소
|
# Cron Triggers:
|
||||||
|
# - 매일 자정(KST): 24시간 경과된 입금 대기 자동 취소 + 정합성 검증
|
||||||
|
# - 매 5분: pending 상태 서버 주문 자동 삭제 (5분 경과)
|
||||||
[triggers]
|
[triggers]
|
||||||
crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00
|
crons = ["0 15 * * *", "*/5 * * * *"] # UTC 15:00 = KST 00:00, 매 5분
|
||||||
|
|
||||||
# Secrets (wrangler secret put 으로 설정):
|
# Secrets (wrangler secret put 으로 설정):
|
||||||
# - BOT_TOKEN: Telegram Bot Token
|
# - BOT_TOKEN: Telegram Bot Token
|
||||||
@@ -62,3 +64,21 @@ crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00
|
|||||||
# - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동)
|
# - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동)
|
||||||
# - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장)
|
# - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장)
|
||||||
# - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장)
|
# - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장)
|
||||||
|
|
||||||
|
# Server Provision Queue
|
||||||
|
[[queues.producers]]
|
||||||
|
queue = "server-provision-queue"
|
||||||
|
binding = "SERVER_PROVISION_QUEUE"
|
||||||
|
|
||||||
|
[[queues.consumers]]
|
||||||
|
queue = "server-provision-queue"
|
||||||
|
max_retries = 3
|
||||||
|
max_batch_size = 10
|
||||||
|
max_batch_timeout = 30
|
||||||
|
max_concurrency = 5
|
||||||
|
dead_letter_queue = "provision-dlq"
|
||||||
|
|
||||||
|
# Dead Letter Queue
|
||||||
|
[[queues.consumers]]
|
||||||
|
queue = "provision-dlq"
|
||||||
|
max_retries = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user