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)
|
||||
);
|
||||
|
||||
-- 서버 상담 세션 테이블
|
||||
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_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_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_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 { handleWebhook } from './routes/webhook';
|
||||
import { handleApiRequest } from './routes/api';
|
||||
@@ -6,7 +6,11 @@ import { handleHealthCheck } from './routes/health';
|
||||
import { parseBankSMS } from './services/bank-sms-parser';
|
||||
import { matchPendingDeposit } from './services/deposit-matcher';
|
||||
import { reconcileDeposits, formatReconciliationReport } from './utils/reconciliation';
|
||||
import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
|
||||
import { timingSafeEqual } from './security';
|
||||
import { createLogger } from './utils/logger';
|
||||
|
||||
const logger = createLogger('worker');
|
||||
|
||||
export default {
|
||||
// HTTP 요청 핸들러
|
||||
@@ -103,17 +107,17 @@ Documentation: https://github.com/your-repo
|
||||
|
||||
// 이메일 주소 마스킹
|
||||
const maskedFrom = message.from.replace(/@.+/, '@****');
|
||||
console.log('[Email] 수신:', maskedFrom, 'Size:', message.rawSize);
|
||||
logger.info('이메일 수신', { from: maskedFrom, size: message.rawSize });
|
||||
|
||||
// SMS 내용 파싱
|
||||
const notification = await parseBankSMS(rawEmail, env);
|
||||
if (!notification) {
|
||||
console.log('[Email] 은행 SMS 파싱 실패');
|
||||
logger.info('은행 SMS 파싱 실패');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파싱 결과 마스킹 로깅
|
||||
console.log('[Email] 파싱 결과:', {
|
||||
logger.info('SMS 파싱 결과', {
|
||||
bankName: notification.bankName,
|
||||
depositorName: notification.depositorName
|
||||
? notification.depositorName.slice(0, 2) + '***'
|
||||
@@ -140,16 +144,16 @@ Documentation: https://github.com/your-repo
|
||||
).run();
|
||||
|
||||
const notificationId = insertResult.meta.last_row_id;
|
||||
console.log('[Email] 알림 저장 완료, ID:', notificationId);
|
||||
logger.info('알림 저장 완료', { notificationId });
|
||||
|
||||
// 자동 매칭 시도
|
||||
const matched = await matchPendingDeposit(env.DB, notificationId, notification);
|
||||
|
||||
// 매칭 결과 로깅 (민감 정보 마스킹)
|
||||
if (matched) {
|
||||
console.log('[Email] 자동 매칭 성공: 거래 ID', matched.transactionId);
|
||||
logger.info('자동 매칭 성공', { transactionId: matched.transactionId });
|
||||
} else {
|
||||
console.log('[Email] 매칭되는 거래 없음');
|
||||
logger.info('매칭되는 거래 없음');
|
||||
}
|
||||
|
||||
// 매칭 성공 시 사용자에게 알림
|
||||
@@ -192,13 +196,120 @@ Documentation: https://github.com/your-repo
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Email] 처리 오류:', error);
|
||||
logger.error('이메일 처리 오류', error as Error);
|
||||
}
|
||||
},
|
||||
|
||||
// Cron Trigger: 만료된 입금 대기 자동 취소 (24시간)
|
||||
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
|
||||
console.log('[Cron] 만료된 입금 대기 정리 시작');
|
||||
// Cron Triggers: 입금 대기 자동 취소 (24시간) + 서버 주문 자동 삭제 (5분)
|
||||
async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
|
||||
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 {
|
||||
// 24시간 이상 된 pending 거래 조회
|
||||
@@ -218,11 +329,11 @@ Documentation: https://github.com/your-repo
|
||||
}>();
|
||||
|
||||
if (!expiredTxs.results?.length) {
|
||||
console.log('[Cron] 만료된 거래 없음');
|
||||
logger.info('만료된 거래 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Cron] 만료된 거래 ${expiredTxs.results.length}건 발견`);
|
||||
logger.info('만료된 거래 발견', { count: expiredTxs.results.length });
|
||||
|
||||
// 단일 UPDATE 쿼리로 일괄 처리
|
||||
const ids = expiredTxs.results.map(tx => tx.id);
|
||||
@@ -232,7 +343,7 @@ Documentation: https://github.com/your-repo
|
||||
WHERE id IN (${ids.map(() => '?').join(',')})`
|
||||
).bind(...ids).run();
|
||||
|
||||
console.log(`[Cron] UPDATE 완료: ${ids.length}건`);
|
||||
logger.info('UPDATE 완료', { count: ids.length });
|
||||
|
||||
// 알림 병렬 처리 (개별 실패가 전체를 중단시키지 않도록 .catch() 추가)
|
||||
const notificationPromises = expiredTxs.results.map(tx =>
|
||||
@@ -245,19 +356,28 @@ Documentation: https://github.com/your-repo
|
||||
`• 입금자: ${tx.depositor_name}\n\n` +
|
||||
`실제 입금하셨다면 다시 신고해주세요.`
|
||||
).catch(err => {
|
||||
console.error(`[Cron] 알림 전송 실패 (거래 #${tx.id}, 사용자 ${tx.telegram_id}):`, err);
|
||||
return null; // 실패한 알림은 null로 처리
|
||||
logger.error('알림 전송 실패', err as Error, {
|
||||
transactionId: tx.id,
|
||||
userId: tx.telegram_id
|
||||
});
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(notificationPromises);
|
||||
console.log(`[Cron] ${expiredTxs.results.length}건 만료 처리 완료 (알림 전송 완료)`);
|
||||
logger.info('만료 처리 완료', { count: expiredTxs.results.length });
|
||||
} catch (error) {
|
||||
console.error('[Cron] 오류:', error);
|
||||
logger.error('Cron 작업 오류', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 예치금 정합성 검증 (Reconciliation)
|
||||
* 실행 주기: 매일 자정 KST (0 15 * * *)
|
||||
*/
|
||||
async function reconcileDepositBalances(env: Env): Promise<void> {
|
||||
logger.info('예치금 정합성 검증 시작');
|
||||
|
||||
// 예치금 정합성 검증 (Reconciliation)
|
||||
console.log('[Cron] 예치금 정합성 검증 시작');
|
||||
try {
|
||||
const report = await reconcileDeposits(env.DB);
|
||||
|
||||
@@ -267,17 +387,19 @@ Documentation: https://github.com/your-repo
|
||||
if (adminId) {
|
||||
const message = formatReconciliationReport(report);
|
||||
await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => {
|
||||
console.error('[Cron] 정합성 검증 알림 전송 실패:', err);
|
||||
logger.error('정합성 검증 알림 전송 실패', err as Error);
|
||||
});
|
||||
} 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) {
|
||||
console.error('[Cron] 정합성 검증 실패:', error);
|
||||
logger.error('정합성 검증 실패', error as Error);
|
||||
// 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ export async function handleCallbackQuery(
|
||||
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 editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
@@ -127,9 +131,18 @@ ${result.error}
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = parts[1];
|
||||
const callbackUserId = parts[1];
|
||||
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) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
|
||||
return;
|
||||
@@ -146,7 +159,7 @@ ${result.error}
|
||||
// 세션 조회
|
||||
const { getServerSession, deleteServerSession } = await import('../../server-agent');
|
||||
|
||||
if (!env.SESSION_KV) {
|
||||
if (!env.DB) {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
@@ -156,7 +169,8 @@ ${result.error}
|
||||
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) {
|
||||
await editMessageText(
|
||||
@@ -177,33 +191,74 @@ ${result.error}
|
||||
messageId,
|
||||
'❌ 선택한 서버를 찾을 수 없습니다.'
|
||||
);
|
||||
await deleteServerSession(env.SESSION_KV, userId);
|
||||
await deleteServerSession(env.DB, telegramUserId);
|
||||
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(
|
||||
'order',
|
||||
{
|
||||
server_id: selected.plan_name, // 임시
|
||||
region_code: selected.region.code,
|
||||
label: `${session.collectedInfo.useCase || 'server'}-1`
|
||||
},
|
||||
env,
|
||||
userId
|
||||
const price = selected.price?.monthly_krw || 0;
|
||||
|
||||
if (!deposit || deposit.balance < price) {
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN, chatId, messageId,
|
||||
`❌ 잔액이 부족합니다.
|
||||
|
||||
• 서버 가격: ${price.toLocaleString()}원/월
|
||||
• 현재 잔액: ${(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(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -215,7 +270,16 @@ ${result.error}
|
||||
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 editMessageText(
|
||||
@@ -228,13 +292,15 @@ ${result.error}
|
||||
// 세션 삭제
|
||||
const { deleteServerSession } = await import('../../server-agent');
|
||||
|
||||
if (env.SESSION_KV) {
|
||||
await deleteServerSession(env.SESSION_KV, userId);
|
||||
if (env.DB) {
|
||||
await deleteServerSession(env.DB, telegramUserId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: server_delete callback handler removed - now using text-based confirmation
|
||||
|
||||
// 알 수 없는 callback data
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,167 @@ export async function handleMessage(
|
||||
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 {
|
||||
// 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('/')) {
|
||||
const [command, ...argParts] = text.split(' ');
|
||||
const args = argParts.join(' ');
|
||||
@@ -74,7 +233,7 @@ export async function handleMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 일반 대화 처리 (ConversationService 위임)
|
||||
// 9. 일반 대화 처리 (ConversationService 위임)
|
||||
const result = await conversationService.processUserMessage(
|
||||
userId,
|
||||
chatIdStr,
|
||||
@@ -87,7 +246,7 @@ export async function handleMessage(
|
||||
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
||||
}
|
||||
|
||||
// 6. 응답 전송 (키보드 포함 여부 확인)
|
||||
// 10. 응답 전송 (키보드 포함 여부 확인)
|
||||
if (result.keyboardData) {
|
||||
console.log('[Webhook] Keyboard data received:', result.keyboardData.type);
|
||||
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({
|
||||
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']),
|
||||
tech_stack: z.array(z.string().min(1).max(100)).max(20).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(),
|
||||
label: z.string().min(1).max(100).optional(),
|
||||
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({
|
||||
@@ -152,6 +155,45 @@ export function selectToolsForMessage(message: string): typeof tools {
|
||||
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
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
@@ -161,109 +203,11 @@ export async function executeTool(
|
||||
db?: D1Database
|
||||
): Promise<string> {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'get_weather': {
|
||||
const result = GetWeatherArgsSchema.safeParse(args);
|
||||
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(', ')}`;
|
||||
const executor = toolExecutors[name];
|
||||
if (executor) {
|
||||
return executor(args, env, telegramUserId, db);
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Tool execution error', error as Error, { name, args });
|
||||
return `⚠️ 도구 실행 중 오류가 발생했습니다.`;
|
||||
|
||||
225
src/types.ts
225
src/types.ts
@@ -14,6 +14,7 @@ export interface Env {
|
||||
DEPOSIT_ADMIN_ID?: string;
|
||||
BRAVE_API_KEY?: string;
|
||||
DEPOSIT_API_SECRET?: string;
|
||||
PROVISION_API_KEY?: string;
|
||||
OPENAI_API_BASE?: string;
|
||||
NAMECHEAP_API_URL?: string;
|
||||
WHOIS_API_URL?: string;
|
||||
@@ -25,6 +26,7 @@ export interface Env {
|
||||
CLOUD_ORCHESTRATOR?: Fetcher; // Service Binding
|
||||
RATE_LIMIT_KV: KVNamespace;
|
||||
SESSION_KV: KVNamespace;
|
||||
SERVER_PROVISION_QUEUE: Queue<ProvisionMessage>;
|
||||
}
|
||||
|
||||
export interface IntentAnalysis {
|
||||
@@ -212,6 +214,8 @@ export interface ManageServerArgs {
|
||||
| "stop"
|
||||
| "delete"
|
||||
| "list"
|
||||
| "info"
|
||||
| "images"
|
||||
| "start_consultation"
|
||||
| "continue_consultation"
|
||||
| "cancel_consultation";
|
||||
@@ -226,6 +230,9 @@ export interface ManageServerArgs {
|
||||
region_code?: string;
|
||||
label?: string;
|
||||
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 {
|
||||
@@ -251,6 +258,7 @@ export interface ServerSession {
|
||||
updatedAt: number;
|
||||
lastRecommendation?: {
|
||||
recommendations: Array<{
|
||||
pricing_id: number; // cloud-instances-db.anvil_pricing.id
|
||||
plan_name: string;
|
||||
provider: string;
|
||||
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)
|
||||
overage_tb?: number;
|
||||
overage_cost_krw?: number;
|
||||
currency?: string; // 통화 단위 (KRW, USD 등)
|
||||
};
|
||||
score: number;
|
||||
max_users: number;
|
||||
@@ -392,9 +401,21 @@ export interface BraveSearchResponse {
|
||||
}
|
||||
|
||||
// OpenAI API 응답 타입
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAIMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string | null;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
name?: string; // For tool responses
|
||||
}
|
||||
|
||||
export interface OpenAIChoice {
|
||||
@@ -438,7 +459,14 @@ export interface ServerOrderKeyboardData {
|
||||
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)
|
||||
export interface BandwidthInfo {
|
||||
@@ -454,6 +482,84 @@ export interface BandwidthInfo {
|
||||
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)
|
||||
export type WorkersAIModel =
|
||||
| "@cf/meta/llama-3.1-8b-instruct"
|
||||
@@ -481,3 +587,116 @@ export interface WorkersAITextGenerationOutput {
|
||||
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"
|
||||
service = "cloud-orchestrator"
|
||||
|
||||
# Cron Trigger: 매일 자정(KST) 실행 - 24시간 경과된 입금 대기 자동 취소
|
||||
# Cron Triggers:
|
||||
# - 매일 자정(KST): 24시간 경과된 입금 대기 자동 취소 + 정합성 검증
|
||||
# - 매 5분: pending 상태 서버 주문 자동 삭제 (5분 경과)
|
||||
[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 으로 설정):
|
||||
# - 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 연동)
|
||||
# - DOMAIN_OWNER_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