From 87c92e1ed137adff212e58270e31cf905d838612 Mon Sep 17 00:00:00 2001 From: kappa Date: Mon, 26 Jan 2026 12:26:21 +0900 Subject: [PATCH] refactor: migrate server provisioning to Cloud Orchestrator service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Queue-based server provisioning (moved to cloud-orchestrator) - Add manage_server tool with Service Binding to Cloud Orchestrator - Add CDN cache hit rate estimation based on tech_stack - Always display bandwidth info (show "포함 범위 내" when no overage) - Add language auto-detection (ko, ja, zh, en) - Update system prompt to always call tools fresh - Add Server System documentation to CLAUDE.md BREAKING: Server provisioning now requires cloud-orchestrator service Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 366 +++--- README.md | 60 + migrations/003_add_server_tables.sql | 52 - migrations/cloud-db-schema.sql | 97 -- package.json | 3 +- schema.sql | 39 - scripts/chat.ts | 117 -- src/constants/messages.ts | 3 + src/constants/server.ts | 68 -- src/index.ts | 19 +- src/openai-service.ts | 5 + src/queue/provision-consumer.ts | 197 ---- src/queue/provision-dlq.ts | 158 --- src/routes/handlers/callback-handler.ts | 649 +---------- src/routes/handlers/message-handler.ts | 495 +------- src/server-provision.ts | 493 -------- src/services/cloud-spec-service.ts | 43 - src/services/linode-api.ts | 234 ---- src/services/server-recommend-service.ts | 65 -- src/services/vultr-api.ts | 240 ---- src/summary-service.ts | 12 +- src/tools/index.ts | 27 +- src/tools/server-tool.ts | 1358 +++++++--------------- src/types.ts | 208 +--- src/utils/session.ts | 208 ---- telegram-cli/README.md | 14 +- wrangler.toml | 49 +- 27 files changed, 695 insertions(+), 4584 deletions(-) delete mode 100644 migrations/003_add_server_tables.sql delete mode 100644 migrations/cloud-db-schema.sql delete mode 100644 scripts/chat.ts delete mode 100644 src/constants/server.ts delete mode 100644 src/queue/provision-consumer.ts delete mode 100644 src/queue/provision-dlq.ts delete mode 100644 src/server-provision.ts delete mode 100644 src/services/cloud-spec-service.ts delete mode 100644 src/services/linode-api.ts delete mode 100644 src/services/server-recommend-service.ts delete mode 100644 src/services/vultr-api.ts delete mode 100644 src/utils/session.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4fa5829..01ac7ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,24 +190,11 @@ npm run deploy # Cloudflare Workers 배포 npm run db:init # D1 스키마 초기화 (production) ⚠️ 주의 npm run db:init:local # D1 스키마 초기화 (local) npm run tail # Workers 로그 스트리밍 -npm run chat # CLI 테스트 클라이언트 npm test # 단위 테스트 실행 (Vitest) npm run test:watch # Watch 모드 npm run test:coverage # 커버리지 리포트 ``` -**CLI 테스트 클라이언트:** -```bash -# .env 파일 생성 (최초 1회) -echo 'WEBHOOK_SECRET=...' > .env # Vault: secret/data/telegram-bot - -# 대화형 모드 -npm run chat - -# 단일 메시지 모드 -npm run chat "날씨 알려줘" -``` - **KV Namespace 생성 (최초 1회):** ```bash # Rate Limiting용 KV Namespace 생성 @@ -215,17 +202,6 @@ wrangler kv:namespace create RATE_LIMIT_KV # 출력된 id를 wrangler.toml의 [[kv_namespaces]] 섹션에 입력 ``` -**Queue 생성 (최초 1회):** -```bash -# 서버 프로비저닝용 Queue 생성 -wrangler queues create server-provision-queue -wrangler queues create provision-dlq - -# Queue 상태 확인 -wrangler queues list -wrangler queues describe server-provision-queue -``` - **Secrets 설정:** ```bash wrangler secret put BOT_TOKEN # Telegram Bot Token @@ -235,9 +211,6 @@ wrangler secret put NAMECHEAP_API_KEY # namecheap-api 래퍼 인증 키 wrangler secret put NAMECHEAP_API_KEY_INTERNAL # Namecheap API 키 (내부용) wrangler secret put BRAVE_API_KEY # Brave Search API 키 wrangler secret put DEPOSIT_API_SECRET # Deposit API 인증 키 -wrangler secret put LINODE_API_KEY # Linode API 키 -wrangler secret put VULTR_API_KEY # Vultr API 키 -wrangler secret put SERVER_ADMIN_ID # 서버 관리 알림 Telegram ID ``` **Webhook 설정:** @@ -266,8 +239,6 @@ wrangler d1 execute telegram-conversations --file=migrations/001_rollback.sql |------|------|--------| | `001_optimize_prefix_indexes.sql` | 입금자명 prefix 인덱스 최적화 (99% 성능 향상) | 2026-01-19 | | `002_add_version_columns.sql` | Optimistic Locking (user_deposits.version) | 2026-01-20 | -| `003_add_server_tables.sql` | 서버 관리 시스템 테이블 추가 | 2026-01-23 | -| `004_seed_server_data.sql` | 서버 제공자/사양 초기 데이터 | 2026-01-23 | **마이그레이션 작업 내용 (001):** - `deposit_transactions.depositor_name_prefix` 컬럼 추가 @@ -422,6 +393,107 @@ curl "https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info?token=${BO --- +## Web Chat Testing (telegram-cli) + +**목적:** Claude Code가 봇과 대화하여 기능을 테스트할 때 사용 + +### API 엔드포인트 + +```bash +# 봇과 대화 +curl -s -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "안녕"}' +``` + +**응답 형식:** +```json +{ + "response": "봇 응답 텍스트...", + "time_ms": 1234 +} +``` + +### Claude가 사용하는 경우 + +**테스트 시나리오:** +```bash +# 1. 기본 대화 +curl -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "안녕하세요"}' + +# 2. 예치금 기능 +curl -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "잔액 조회"}' + +# 3. 도메인 기능 +curl -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "example.com 조회"}' + +# 4. Function Calling 도구 +curl -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "서울 날씨"}' +``` + +### 다른 엔드포인트 + +| 엔드포인트 | 메서드 | 설명 | +|-----------|--------|------| +| `/` | GET | 웹 채팅 UI (브라우저) | +| `/health` | GET | Health check | + +### 배포 정보 + +**Worker URL**: https://telegram-cli-web.kappa-d8e.workers.dev +**별도 Worker**: 메인 봇과 독립적으로 배포됨 +**상세 문서**: [telegram-cli/README.md](../telegram-cli/README.md) + +**Secrets 필요:** +- `BOT_TOKEN`: Telegram Bot Token (Vault: telegram-bot) +- `WEBHOOK_SECRET`: Bot Worker /api/test 인증용 (Vault: telegram-bot) + +**배포 명령:** +```bash +cd telegram-cli +npm install +wrangler secret put BOT_TOKEN +wrangler secret put WEBHOOK_SECRET +npm run deploy +``` + +### 아키텍처 + +``` +Claude Code (또는 브라우저) + ↓ +POST /api/chat → telegram-cli Worker + ↓ + Bot Worker (/api/test) + - 메인 봇과 동일한 로직 + - DB 저장/조회 + - AI 응답 생성 + - Function Calling + ↓ + 응답 반환 { response, time_ms } +``` + +**특징:** +- 별도 Worker로 분리되어 메인 봇에 영향 없음 +- 동일한 Bot Worker의 /api/test 엔드포인트 호출 +- 응답 시간 측정 자동 포함 +- username: 'web-tester'로 자동 설정 + +**사용 사례:** +- Claude Code가 봇 기능 테스트 +- 개발자가 브라우저에서 빠른 테스트 +- CI/CD 파이프라인에서 자동 테스트 + +--- + ## Troubleshooting ### 자주 발생하는 에러 @@ -614,7 +686,7 @@ Telegram Webhook → Security Validation → Command/Message Router **Core Services:** | 파일 | 역할 | 주요 함수 | |------|------|----------| -| `index.ts` | Worker 진입점, Email Handler, Queue Handler | `fetch()`, `email()`, `queue()` | +| `index.ts` | Worker 진입점, Email Handler | `fetch()`, `email()` | | `openai-service.ts` | AI 응답 + Function Calling | `generateResponse()`, `executeFunctionCall()` | | `summary-service.ts` | 프로필 시스템 | `updateSummary()`, `getConversationContext()` | | `deposit-agent.ts` | 예치금 함수 (코드 직접 처리) | `executeDepositFunction()` | @@ -622,9 +694,6 @@ Telegram Webhook → Security Validation → Command/Message Router | `services/notification.ts` | 관리자 알림 (Circuit Breaker, Retry 실패) | `notifyAdmin()` | | `commands.ts` | 봇 명령어 | `handleCommand()` | | `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` | -| `queue/provision-consumer.ts` | 서버 생성 Queue Consumer | `handleProvisionQueue()` | -| `queue/provision-dlq.ts` | Dead Letter Queue 핸들러 (환불 처리) | `handleProvisionDLQ()` | -| `server-provision.ts` | 서버 프로비저닝 로직 | `executeServerProvision()` | **Logging & Monitoring (Phase 5-3):** | 파일 | 역할 | 주요 기능 | @@ -658,7 +727,7 @@ end(); // duration 자동 기록 | 도메인 | `manage_domain` | 코드 직접 처리 → Namecheap | 도메인, 네임서버, WHOIS | | 도메인 추천 | `suggest_domains` | GPT + Namecheap | **도메인 추천, 도메인 제안, 도메인 아이디어** | | 예치금 | `manage_deposit` | 코드 직접 처리 | **입금, 충전, 잔액, 계좌, 송금** | -| 서버 | `manage_server` | Linode/Vultr API | **서버, VPS, 클라우드, 인스턴스** | +| 서버 | `manage_server` | Cloud Orchestrator (Service Binding) | **서버, VPS, 클라우드, 호스팅** | **Data Layer (D1 SQLite):** | 테이블 | 용도 | 주요 컬럼 | @@ -670,16 +739,6 @@ end(); // duration 자동 기록 | `deposit_transactions` | 거래 내역 | user_id, amount, status | | `bank_notifications` | SMS 파싱 | depositor_name, amount, bank | | `user_domains` | 도메인 소유권 | user_id, domain, verified (등록 시 자동 추가) | -| `cloud_providers` | 클라우드 제공자 | name, api_base_url, enabled | -| `instance_specs` | 서버 사양표 | plan_id, vcpus, memory_mb, price_krw, regions | -| `server_orders` | 서버 주문 내역 | user_id, provider_id, status, ip_address | -| `user_servers` | 서버 소유권 | user_id, provider_instance_id, verified | - -**Queue System (Server Provisioning):** -| Queue | 역할 | Consumer | -|-------|------|----------| -| `server-provision-queue` | 서버 생성 요청 큐 | provision-consumer.ts | -| `provision-dlq` | 실패한 요청 (환불 처리) | provision-dlq.ts | **AI Fallback:** OpenAI 미설정 시 Workers AI (Llama 3.1 8B) 자동 전환 @@ -831,7 +890,6 @@ case 'new_tool': | `MAX_SUMMARIES_PER_USER` | 3 | 유지할 프로필 버전 수 | | `DOMAIN_OWNER_ID` | - | 도메인 관리 권한 Telegram ID | | `DEPOSIT_ADMIN_ID` | - | 예치금 관리 권한 Telegram ID | -| `SERVER_ADMIN_ID` | - | 서버 관리 권한 Telegram ID | **외부 API 엔드포인트 (커스터마이징 가능):** | 변수 | 기본값 | 설명 | @@ -858,9 +916,6 @@ case 'new_tool': | `NAMECHEAP_API_KEY_INTERNAL` | Namecheap API 키 (내부) | - | | `BRAVE_API_KEY` | Brave Search API 키 | - | | `DEPOSIT_API_SECRET` | Deposit API 인증 | - | -| `LINODE_API_KEY` | Linode API 키 | - | -| `VULTR_API_KEY` | Vultr API 키 | - | -| `SERVER_ADMIN_ID` | 서버 관리 알림 Telegram ID | - | **KV Namespaces:** | Binding | 설명 | 생성 명령 | @@ -1159,6 +1214,71 @@ wrangler d1 execute telegram-conversations --command "SELECT user_id, balance, v --- +## Server System + +**목적:** 클라우드 서버 추천 및 관리 (Cloud Orchestrator 연동) + +**연결 방식:** Cloudflare Service Binding (Worker-to-Worker 직접 통신) + +**manage_server 도구 파라미터:** +```typescript +{ + action: 'recommend' | 'order' | 'start' | 'stop' | 'delete' | 'list', + tech_stack?: string[], // recommend용 (필수) + expected_users?: number, // recommend용 (필수) + use_case?: string, // recommend용 (필수) + traffic_pattern?: 'steady' | 'spiky' | 'growing', + region_preference?: string[], + budget_limit?: number, + lang?: 'ko' | 'ja' | 'zh' | 'en', // 자동 감지 + server_id?: string, // order/start/stop/delete용 + region_code?: string, // order용 + label?: string, // order용 +} +``` + +**action별 상태:** +| action | 설명 | 상태 | +|--------|------|------| +| `recommend` | 서버 추천 | ✅ 구현 완료 | +| `order` | 서버 신청 | 🚧 준비 중 | +| `start` | 서버 시작 | 🚧 준비 중 | +| `stop` | 서버 중지 | 🚧 준비 중 | +| `delete` | 서버 해지 | 🚧 준비 중 | +| `list` | 내 서버 목록 | 🚧 준비 중 | + +**추천 결과 포맷:** +``` +🖥️ 서버 추천 결과 + +1️⃣ Standard 8GB (Anvil) + • 스펙: 4vCPU / 8GB / 160GB SSD + • 리전: Tokyo 3 (JP) + • 가격: ₩69,719/월 (대역폭 5TB) + • 예상 트래픽: 1.7TB (포함 범위 내) ← 항상 표시 + • 점수: 95점 / 최대 7,500명 +``` + +**대역폭 정보 표시 규칙:** +- 초과 없음: `예상 트래픽: X.XTB (포함 범위 내)` +- 초과 있음: `예상 트래픽: X.XTB → 초과 X.XTB (₩X,XXX)` + +**CDN 캐시 히트율 추정:** +- tech_stack에 `cloudflare`, `cdn` 등 포함 시 자동 적용 +- 비디오 스트리밍: 92%, 정적 사이트: 95%, API: 30%, 이커머스: 70% + +**언어 자동 감지:** +- 한글 → `ko`, 히라가나/가타카나 → `ja`, 한자 → `zh`, 기본값 → `en` + +**Service Binding 설정 (wrangler.toml):** +```toml +[[services]] +binding = "CLOUD_ORCHESTRATOR" +service = "cloud-orchestrator" +``` + +--- + ## Domain System **아키텍처 변경 (2025-01):** Agent 기반 → 코드 직접 처리 @@ -1310,153 +1430,3 @@ POST /api/deposit/deduct # 잔액 차감 { telegram_id, amount, reason } Header: X-API-Key: DEPOSIT_API_SECRET ``` - ---- - -## Server System (Queue-based Provisioning) - -**아키텍처:** Queue 기반 비동기 처리로 긴 프로비저닝 작업 대응 - -### Queue 기반 비동기 처리 흐름 - -``` -사용자: "서버 생성해줘" - ↓ -메인 AI: manage_server(action="order", ...) - ↓ -1. 잔액 확인 + 차감 (즉시) -2. server_orders 테이블에 status='pending' 저장 -3. Queue에 메시지 전송 (SERVER_PROVISION_QUEUE.send()) - ↓ -사용자 응답: "⏳ 서버 생성 요청 접수 (#123)" - ↓ - ┌───────────────────────────────────┐ - │ Queue Consumer (비동기 처리) │ - │ provision-consumer.ts │ - └───────────────────────────────────┘ - ↓ -executeServerProvision(): - 1. Linode/Vultr API 호출 (5-30초 소요) - 2. DB 업데이트: status='completed', ip_address, root_password - 3. user_servers 테이블에 소유권 추가 - ↓ -성공 시 → 사용자 알림 (IP, 비밀번호) -실패 시 → 재시도 (최대 3회) → DLQ -``` - -### 보안 개선사항 - -**비밀번호 보안:** -```typescript -// ❌ 이전: 평문 저장 (보안 취약) -root_password: string - -// ✅ 현재: 해시 저장 + 일회성 전송 -root_password_hash: string // DB 저장용 (bcrypt) -root_password: string // Queue 메시지에만 포함 (사용자 알림 후 파기) -``` - -**Queue 메시지 구조:** -```typescript -interface ProvisionMessage { - order_id: number; - user_id: number; - telegram_user_id: number; - chat_id: number; -} -``` - -### Retry & DLQ 정책 - -**재시도 정책 (wrangler.toml):** -```toml -[[queues.consumers]] -queue = "server-provision-queue" -max_retries = 3 # 최대 3회 재시도 -max_batch_size = 1 # 순차 처리 -max_batch_timeout = 30 # 30초 타임아웃 -max_concurrency = 3 # 최대 3개 동시 처리 -dead_letter_queue = "provision-dlq" -``` - -**retryable 플래그:** -```typescript -// provision-consumer.ts에서 활용 -if (result.retryable === false) { - // 재시도하면 안 되는 경우 (예: 잘못된 파라미터) - message.ack(); // DLQ로 보내지 않고 종료 -} else { - // 일시적 오류 - 재시도 (max 3회 → DLQ) - message.retry(); -} -``` - -**DLQ 처리 (provision-dlq.ts):** -``` -1. DB 상태 업데이트: status='failed' -2. 잔액 환불 처리 (이미 차감된 경우) - - user_deposits.balance 복원 - - deposit_transactions에 'refund' 거래 추가 -3. 사용자 알림 (환불 정보 포함) -4. 관리자 알림 (notifyAdmin) -5. DLQ에서 제거 (ack) - 무한 루프 방지 -``` - -### 관련 파일 - -| 파일 | 역할 | -|------|------| -| `queue/provision-consumer.ts` | Queue Consumer (서버 생성 실행) | -| `queue/provision-dlq.ts` | DLQ 핸들러 (환불 처리) | -| `server-provision.ts` | 실제 프로비저닝 로직 (Linode/Vultr API 호출) | -| `index.ts:queue()` | Queue 메시지 라우팅 | - -### manage_server 도구 파라미터 - -```typescript -{ - action: 'list' | 'search' | 'order' | 'info' | 'recommend', - provider?: 'linode' | 'vultr', - plan?: string, - region?: string, - ... -} -``` - -### 사용자 알림 메시지 예시 - -**성공:** -``` -🎉 서버 생성 완료! - -주문번호: #123 - -📋 서버 정보 -• 사양: Linode 2GB -• 리전: Tokyo 2 -• IP 주소: 203.0.113.5 -• 인스턴스 ID: 12345678 - -🔐 접속 정보 -• Root 비밀번호: [REDACTED] - -📌 SSH 접속 명령어 -ssh root@203.0.113.5 - -⚠️ 보안 안내 -• 비밀번호는 이 메시지에서만 확인 가능합니다. -• 접속 후 즉시 변경해주세요. -``` - -**실패 (DLQ):** -``` -❌ 서버 생성 실패 - -주문번호: #123 - -일시적인 문제로 서버를 생성할 수 없습니다. - -✅ 결제 금액이 환불되었습니다. - -관리자가 확인 후 연락드리겠습니다. -``` diff --git a/README.md b/README.md index 66e8065..79b7143 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ * 🌐 **도메인 관리**: 도메인 검색, 추천(AI), 가격 조회, 등록, DNS 관리 통합 * 🖥️ **서버 관리 (Queue 기반)**: Linode/Vultr 인스턴스 검색, 비교, 주문, 비동기 프로비저닝 * ⚡ **서버리스**: Cloudflare Workers + Queues로 긴 작업도 안정적 처리 +* 🌐 **웹 채팅 UI**: 브라우저와 API로 봇 테스트 가능 ([telegram-cli](./telegram-cli/README.md)) ### 🚀 성능 최적화 @@ -216,6 +217,65 @@ npx @apidevtools/swagger-cli validate openapi.yaml --- +## 🌐 웹 채팅 인터페이스 (telegram-cli) + +별도 Cloudflare Worker로 배포된 웹 채팅 UI 및 JSON API입니다. + +### 엔드포인트 + +**배포 URL**: https://telegram-cli-web.kappa-d8e.workers.dev + +| 엔드포인트 | 메서드 | 설명 | +|-----------|--------|------| +| `/` | GET | 웹 채팅 UI (브라우저 인터페이스) | +| `/api/chat` | POST | JSON API (프로그래밍 방식 접근) | +| `/health` | GET | Health check | + +### 사용 예시 + +#### 웹 브라우저 +``` +https://telegram-cli-web.kappa-d8e.workers.dev +``` + +브라우저에서 접속하여 봇과 실시간 대화 + +#### JSON API (curl) +```bash +curl -s -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "안녕"}' +``` + +**응답:** +```json +{ + "response": "안녕하세요! 무엇을 도와드릴까요?", + "time_ms": 1234 +} +``` + +#### Claude Code 사용 +Claude는 이 API를 사용하여 봇과 대화하고 기능을 테스트할 수 있습니다. + +```bash +# 예치금 기능 테스트 +curl -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "잔액 조회"}' + +# 도메인 기능 테스트 +curl -X POST 'https://telegram-cli-web.kappa-d8e.workers.dev/api/chat' \ + -H 'Content-Type: application/json' \ + -d '{"message": "example.com 조회"}' +``` + +### 배포 방법 + +자세한 내용은 [telegram-cli/README.md](./telegram-cli/README.md)를 참조하세요. + +--- + ## 🧪 테스트 ### 자동화된 단위 테스트 diff --git a/migrations/003_add_server_tables.sql b/migrations/003_add_server_tables.sql deleted file mode 100644 index 8a2016c..0000000 --- a/migrations/003_add_server_tables.sql +++ /dev/null @@ -1,52 +0,0 @@ --- Migration 003: Add server management tables --- Purpose: Cloud server order tracking --- Date: 2026-01-23 --- Reference: CLAUDE.md "Server Management System" --- --- Background: --- Telegram 봇을 통해 클라우드 서버 주문 내역을 기록하고 관리합니다. --- 예치금 시스템과 통합하여 자동 결제를 지원합니다. --- 서버 사양(cloud_providers, instance_specs)은 별도 외부 시스템에서 관리합니다. - --- Step 1: Create server_orders table (order lifecycle tracking) -CREATE TABLE IF NOT EXISTS server_orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - spec_id INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'provisioning', 'active', 'failed', 'cancelled', 'terminated')), - region TEXT NOT NULL, - provider_instance_id TEXT, - ip_address TEXT, - root_password TEXT, - price_paid INTEGER NOT NULL, - error_message TEXT, - provisioned_at DATETIME, - terminated_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) -); - --- Step 2: Create user_servers table (ownership mapping) -CREATE TABLE IF NOT EXISTS user_servers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - order_id INTEGER UNIQUE NOT NULL, - provider_id INTEGER NOT NULL, - label TEXT, - verified INTEGER DEFAULT 0, - 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) -); - --- Step 3: Create indexes for efficient queries -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, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id); -CREATE INDEX IF NOT EXISTS idx_user_servers_provider ON user_servers(provider_id); - --- Verification Queries (주석으로 제공) --- SELECT * FROM server_orders WHERE user_id = 1 ORDER BY created_at DESC; --- SELECT * FROM user_servers WHERE user_id = 1; diff --git a/migrations/cloud-db-schema.sql b/migrations/cloud-db-schema.sql deleted file mode 100644 index 4df5486..0000000 --- a/migrations/cloud-db-schema.sql +++ /dev/null @@ -1,97 +0,0 @@ --- CLOUD_DB Schema for local development --- Auto-generated from production - -CREATE TABLE IF NOT EXISTS providers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - api_base_url TEXT, - last_sync_at TEXT, - sync_status TEXT NOT NULL DEFAULT 'pending' CHECK (sync_status IN ('pending', 'syncing', 'success', 'error')), - sync_error TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS regions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id INTEGER NOT NULL, - region_code TEXT NOT NULL, - region_name TEXT NOT NULL, - country_code TEXT, - latitude REAL, - longitude REAL, - available INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE, - UNIQUE(provider_id, region_code) -); - -CREATE TABLE IF NOT EXISTS instance_types ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id INTEGER NOT NULL, - instance_id TEXT NOT NULL, - instance_name TEXT NOT NULL, - vcpu INTEGER NOT NULL, - memory_mb INTEGER NOT NULL, - storage_gb INTEGER NOT NULL, - transfer_tb REAL, - network_speed_gbps REAL, - gpu_count INTEGER DEFAULT 0, - gpu_type TEXT, - instance_family TEXT CHECK (instance_family IN ('general', 'compute', 'memory', 'storage', 'gpu')), - metadata TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE, - UNIQUE(provider_id, instance_id) -); - -CREATE TABLE IF NOT EXISTS pricing ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - instance_type_id INTEGER NOT NULL, - region_id INTEGER NOT NULL, - hourly_price REAL NOT NULL, - monthly_price REAL NOT NULL, - currency TEXT NOT NULL DEFAULT 'USD', - available INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - hourly_price_krw REAL, - monthly_price_krw REAL, - hourly_price_retail REAL, - monthly_price_retail REAL, - FOREIGN KEY (instance_type_id) REFERENCES instance_types(id) ON DELETE CASCADE, - FOREIGN KEY (region_id) REFERENCES regions(id) ON DELETE CASCADE, - UNIQUE(instance_type_id, region_id) -); - --- Seed test data -INSERT OR IGNORE INTO providers (id, name, display_name, api_base_url, sync_status) VALUES - (1, 'linode', 'Linode (Akamai)', 'https://api.linode.com/v4', 'success'), - (2, 'vultr', 'Vultr', 'https://api.vultr.com/v2', 'success'); - -INSERT OR IGNORE INTO regions (id, provider_id, region_code, region_name, country_code, available) VALUES - (1, 1, 'ap-northeast', 'Tokyo 2, JP', 'JP', 1), - (2, 1, 'ap-south', 'Singapore, SG', 'SG', 1), - (3, 2, 'nrt', 'Tokyo', 'JP', 1), - (4, 2, 'sgp', 'Singapore', 'SG', 1); - -INSERT OR IGNORE INTO instance_types (id, provider_id, instance_id, instance_name, vcpu, memory_mb, storage_gb, transfer_tb, instance_family) VALUES - (1, 1, 'g6-nanode-1', 'Nanode 1GB', 1, 1024, 25, 1, 'general'), - (2, 1, 'g6-standard-1', 'Linode 2GB', 1, 2048, 50, 2, 'general'), - (3, 1, 'g6-standard-2', 'Linode 4GB', 2, 4096, 80, 4, 'general'), - (4, 2, 'vc2-1c-1gb', 'Cloud Compute 1GB', 1, 1024, 25, 1, 'general'), - (5, 2, 'vc2-1c-2gb', 'Cloud Compute 2GB', 1, 2048, 55, 2, 'general'), - (6, 2, 'vc2-2c-4gb', 'Cloud Compute 4GB', 2, 4096, 80, 3, 'general'); - -INSERT OR IGNORE INTO pricing (id, instance_type_id, region_id, hourly_price, monthly_price, monthly_price_krw, available) VALUES - (1, 1, 1, 0.0075, 5.0, 7500, 1), - (2, 2, 1, 0.018, 12.0, 18000, 1), - (3, 3, 1, 0.036, 24.0, 36000, 1), - (4, 1, 2, 0.0075, 5.0, 7500, 1), - (5, 4, 3, 0.007, 5.0, 7500, 1), - (6, 5, 3, 0.015, 10.0, 15000, 1), - (7, 6, 3, 0.03, 20.0, 30000, 1), - (8, 4, 4, 0.007, 5.0, 7500, 1); diff --git a/package.json b/package.json index 87ac8e2..ef0fe4c 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,11 @@ "main": "src/index.ts", "scripts": { "dev": "wrangler dev", - "deploy": "sed -i '' 's/ENVIRONMENT = \"development\"/ENVIRONMENT = \"production\"/' wrangler.toml && wrangler deploy && sed -i '' 's/ENVIRONMENT = \"production\"/ENVIRONMENT = \"development\"/' wrangler.toml", + "deploy": "wrangler deploy", "db:create": "wrangler d1 create telegram-summary-db", "db:init": "wrangler d1 execute telegram-summary-db --file=schema.sql", "db:init:local": "wrangler d1 execute telegram-summary-db --local --file=schema.sql", "tail": "wrangler tail", - "chat": "npx tsx scripts/chat.ts", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" diff --git a/schema.sql b/schema.sql index a4b1ffd..bd202b3 100644 --- a/schema.sql +++ b/schema.sql @@ -100,42 +100,3 @@ 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 TABLE IF NOT EXISTS server_orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - spec_id INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'provisioning', 'active', 'failed', 'cancelled', 'terminated')), - region TEXT NOT NULL, - provider_instance_id TEXT, - ip_address TEXT, - root_password TEXT, - price_paid INTEGER NOT NULL, - error_message TEXT, - provisioned_at DATETIME, - terminated_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - 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 UNIQUE NOT NULL, - provider_id INTEGER NOT NULL, - label TEXT, - verified INTEGER DEFAULT 0, - 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_server_orders_user ON server_orders(user_id); -CREATE INDEX IF NOT EXISTS idx_server_orders_status ON server_orders(status, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id); -CREATE INDEX IF NOT EXISTS idx_user_servers_provider ON user_servers(provider_id); diff --git a/scripts/chat.ts b/scripts/chat.ts deleted file mode 100644 index bfba813..0000000 --- a/scripts/chat.ts +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Telegram Bot CLI Chat Client - * - Worker의 /api/test 엔드포인트를 통해 직접 대화 - * - 사용법: npm run chat - * 또는: npm run chat "메시지" - */ - -import * as readline from 'readline'; -import * as fs from 'fs'; -import * as path from 'path'; - -// .env 파일 로드 -const envPath = path.join(process.cwd(), '.env'); -if (fs.existsSync(envPath)) { - const envContent = fs.readFileSync(envPath, 'utf-8'); - for (const line of envContent.split('\n')) { - const [key, ...valueParts] = line.split('='); - if (key && valueParts.length > 0) { - process.env[key.trim()] = valueParts.join('=').trim(); - } - } -} - -const WORKER_URL = process.env.WORKER_URL || 'https://telegram-summary-bot.kappa-d8e.workers.dev'; -const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; -const USER_ID = process.env.TELEGRAM_USER_ID || '821596605'; - -interface TestResponse { - input: string; - response: string; - user_id: string; - error?: string; -} - -async function sendMessage(text: string): Promise { - if (!WEBHOOK_SECRET) { - return '❌ WEBHOOK_SECRET 환경변수가 필요합니다.\n export WEBHOOK_SECRET="your-secret"'; - } - - try { - const response = await fetch(`${WORKER_URL}/api/test`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - text, - user_id: USER_ID, - secret: WEBHOOK_SECRET, - }), - }); - - const data = await response.json() as TestResponse; - - if (data.error) { - return `❌ Error: ${data.error}`; - } - - return data.response; - } catch (error) { - return `❌ Request failed: ${error}`; - } -} - -async function interactiveMode() { - console.log('🤖 Telegram Bot CLI'); - console.log(`📡 ${WORKER_URL}`); - console.log(`👤 User: ${USER_ID}`); - console.log('─'.repeat(40)); - console.log('메시지를 입력하세요. 종료: exit\n'); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const prompt = () => { - rl.question('\x1b[36m>\x1b[0m ', async (input) => { - const text = input.trim(); - - if (text === 'exit' || text === 'quit' || text === 'q') { - console.log('👋 종료'); - rl.close(); - process.exit(0); - } - - if (!text) { - prompt(); - return; - } - - console.log('\x1b[33m⏳ 처리 중...\x1b[0m'); - const response = await sendMessage(text); - console.log(`\n\x1b[32m🤖\x1b[0m ${response}\n`); - prompt(); - }); - }; - - prompt(); -} - -async function main() { - const args = process.argv.slice(2); - - if (args.length > 0) { - // 단일 메시지 모드 - const text = args.join(' '); - const response = await sendMessage(text); - console.log(response); - } else { - // 대화형 모드 - await interactiveMode(); - } -} - -main().catch(console.error); diff --git a/src/constants/messages.ts b/src/constants/messages.ts index 6408d8f..bdbed8a 100644 --- a/src/constants/messages.ts +++ b/src/constants/messages.ts @@ -26,6 +26,9 @@ export const ERROR_MESSAGES = { // 날씨 관련 WEATHER_SERVICE_UNAVAILABLE: '날씨 정보를 가져올 수 없습니다', + + // 서버 관련 + SERVER_SERVICE_UNAVAILABLE: '🖥️ 서버 관리 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.', } as const; export type ErrorMessageKey = keyof typeof ERROR_MESSAGES; diff --git a/src/constants/server.ts b/src/constants/server.ts deleted file mode 100644 index 2ff41d0..0000000 --- a/src/constants/server.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Server provisioning constants - * Centralized OS images, region mappings, and display helpers - */ - -/** - * OS image mappings - * Maps OS image IDs to user-friendly display names - */ -export const OS_IMAGES = { - 'ubuntu-22.04': 'Ubuntu 22.04 LTS', - 'ubuntu-24.04': 'Ubuntu 24.04 LTS', - 'debian-12': 'Debian 12', - 'centos-stream-9': 'CentOS Stream 9' -} as const; - -export type OSImageKey = keyof typeof OS_IMAGES; - -/** - * Region code to flag emoji and localized name mapping - * Covers both Linode and Vultr region codes - */ -export const REGION_FLAGS: Record = { - // Linode - 'ap-northeast': { flag: '🇯🇵', name: '오사카' }, - 'ap-south': { flag: '🇸🇬', name: '싱가포르' }, - 'ap-southeast': { flag: '🇦🇺', name: '시드니' }, - 'ap-west': { flag: '🇮🇳', name: '뭄바이' }, - 'us-west': { flag: '🇺🇸', name: 'LA' }, - 'us-central': { flag: '🇺🇸', name: '댈러스' }, - 'us-east': { flag: '🇺🇸', name: '뉴저지' }, - 'eu-west': { flag: '🇬🇧', name: '런던' }, - 'eu-central': { flag: '🇩🇪', name: '프랑크푸르트' }, - // Vultr - 'nrt': { flag: '🇯🇵', name: '도쿄' }, - 'icn': { flag: '🇰🇷', name: '서울' }, - 'sgp': { flag: '🇸🇬', name: '싱가포르' }, - 'syd': { flag: '🇦🇺', name: '시드니' }, - 'lax': { flag: '🇺🇸', name: 'LA' }, - 'ord': { flag: '🇺🇸', name: '시카고' }, - 'ewr': { flag: '🇺🇸', name: '뉴저지' }, - 'lhr': { flag: '🇬🇧', name: '런던' }, - 'fra': { flag: '🇩🇪', name: '프랑크푸르트' }, -}; - -/** - * Number emojis for list display (1-5) - */ -export const NUM_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣'] as const; - -/** - * Get formatted region display with flag and name - * @param regionCode - Region code (e.g., 'ap-northeast', 'nrt') - * @returns Formatted string like "🇯🇵 오사카" or original code if not found - */ -export function getRegionDisplay(regionCode: string): string { - const info = REGION_FLAGS[regionCode]; - return info ? `${info.flag} ${info.name}` : regionCode; -} - -/** - * Get user-friendly OS display name - * @param osImage - OS image ID (e.g., 'ubuntu-22.04') - * @returns Display name like "Ubuntu 22.04 LTS" or original ID if not found - */ -export function getOSDisplayName(osImage: string): string { - return OS_IMAGES[osImage as OSImageKey] || osImage; -} diff --git a/src/index.ts b/src/index.ts index ac1d207..6ba28dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Env, EmailMessage, ProvisionMessage } from './types'; +import { Env, EmailMessage } from './types'; import { sendMessage, setWebhook, getWebhookInfo } from './telegram'; import { handleWebhook } from './routes/webhook'; import { handleApiRequest } from './routes/api'; @@ -6,8 +6,6 @@ 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 } from './queue/provision-consumer'; -import { handleProvisionDLQ } from './queue/provision-dlq'; export default { // HTTP 요청 핸들러 @@ -281,19 +279,4 @@ Documentation: https://github.com/your-repo // 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행 } }, - - // Queue 핸들러 (서버 프로비저닝) - async queue(batch: MessageBatch, env: Env): Promise { - const QUEUE_HANDLERS: Record, env: Env) => Promise> = { - 'server-provision-queue': handleProvisionQueue, - 'provision-dlq': handleProvisionDLQ, - }; - - const handler = QUEUE_HANDLERS[batch.queue]; - if (handler) { - return handler(batch, env); - } - - console.error(`[Queue] Unknown queue: ${batch.queue}`); - }, }; diff --git a/src/openai-service.ts b/src/openai-service.ts index 0a3f4dc..5405fd3 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -148,6 +148,11 @@ export async function generateOpenAIResponse( return result; } + // __DIRECT__ 마커가 있으면 AI 재해석 없이 바로 반환 (서버 추천 등) + if (result.includes('__DIRECT__')) { + return result.replace('__DIRECT__', '').trim(); + } + toolResults.push({ role: 'tool', tool_call_id: toolCall.id, diff --git a/src/queue/provision-consumer.ts b/src/queue/provision-consumer.ts deleted file mode 100644 index 2758ad3..0000000 --- a/src/queue/provision-consumer.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Server Provisioning Queue Consumer - * - * Purpose: Handle asynchronous server provisioning from Queue - * - * Flow: - * 1. Receive message from PROVISION_QUEUE - * 2. Call executeServerProvision() - * 3. On success: Send user notification + ack() - * 4. On failure: Send error notification + retry() (max 3 attempts → DLQ) - * - * Retry Policy: - * - Max retries: 3 - * - On exhaustion: Move to Dead Letter Queue - * - Manual intervention required for DLQ messages - */ - -import { createLogger } from '../utils/logger'; -import { executeServerProvision } from '../server-provision'; -import { sendMessage } from '../telegram'; -import { notifyAdmin } from '../services/notification'; -import type { Env, ProvisionMessage } from '../types'; - -const logger = createLogger('provision-consumer'); - -/** - * Handle incoming messages from PROVISION_QUEUE - * - * @param batch - Message batch from Queue - * @param env - Environment variables (API keys, DB) - */ -export async function handleProvisionQueue( - batch: MessageBatch, - env: Env -): Promise { - for (const message of batch.messages) { - const { order_id, user_id, telegram_user_id, chat_id } = message.body; - - logger.info('서버 생성 큐 처리 시작', { - order_id, - user_id, - attempt: message.attempts, - queue_timestamp: message.timestamp, - }); - - try { - // Execute server provisioning - const result = await executeServerProvision( - env, - user_id, - telegram_user_id, - order_id - ); - - if (result.success) { - // Success: Send user notification with server details - const successMessage = `🎉 서버 생성 완료! - -주문번호: #${result.order_id} - -📋 서버 정보 -• 사양: ${result.plan_label || 'Unknown'} -• 리전: ${result.region || 'Unknown'} -• IP 주소: ${result.ip_address || 'N/A'} -• 인스턴스 ID: ${result.instance_id || 'N/A'} - -🔐 접속 정보 -• Root 비밀번호: ${result.root_password || 'N/A'} - -📌 SSH 접속 명령어 -ssh root@${result.ip_address || 'IP_ADDRESS'} - -⚠️ 보안 안내 -• 비밀번호는 이 메시지에서만 확인 가능합니다. -• 접속 후 즉시 변경해주세요. -• 방화벽 설정을 권장합니다.`; - - await sendMessage( - env.BOT_TOKEN, - chat_id, - successMessage, - { parse_mode: 'HTML' } - ); - - logger.info('서버 생성 성공 알림 전송', { - order_id: result.order_id, - instance_id: result.instance_id, - ip: result.ip_address, - chat_id, - // root_password는 로그에서 제외 (보안) - }); - - // Acknowledge message (remove from queue) - message.ack(); - - } else { - // Provisioning failed - send error notification - const errorMessage = `❌ 서버 생성 실패 - -주문번호: #${order_id} - -에러: ${result.error || 'Unknown error'} - -${message.attempts < 3 ? '자동으로 재시도합니다...' : '관리자에게 문의하세요.'}`; - - await sendMessage( - env.BOT_TOKEN, - chat_id, - errorMessage, - { parse_mode: 'HTML' } - ); - - logger.error('서버 생성 실패', new Error(result.error || 'Unknown error'), { - order_id, - attempt: message.attempts, - user_id, - retryable: result.retryable, - }); - - // retryable 플래그 확인 - if (result.retryable === false) { - // 재시도하면 안 되는 경우 (예: 잘못된 파라미터) - logger.warn('서버 생성 실패 - 재시도 불가', { - order_id, - retryable: false, - error: result.error, - }); - message.ack(); // DLQ로 보내지 않고 종료 - } else { - // 일시적 오류 - 재시도 (will move to DLQ after max_retries) - logger.warn('서버 생성 실패 - 재시도 예정', { - order_id, - attempt: message.attempts, - }); - message.retry(); - } - } - - } catch (error) { - const err = error as Error; - logger.error('서버 생성 큐 처리 중 예외 발생', err, { - order_id, - attempt: message.attempts, - user_id, - stack: err.stack, - }); - - // Send error notification to user - try { - const fatalErrorMessage = `❌ 서버 생성 처리 오류 - -주문번호: #${order_id} - -시스템 오류가 발생했습니다. -${message.attempts < 3 ? '자동으로 재시도합니다...' : '관리자에게 문의하세요.'}`; - - await sendMessage( - env.BOT_TOKEN, - chat_id, - fatalErrorMessage, - { parse_mode: 'HTML' } - ); - } catch (notifyError) { - // Failed to send notification - log only - logger.error('사용자 알림 전송 실패', notifyError as Error, { order_id, chat_id }); - } - - // Notify admin if max retries exhausted - if (message.attempts >= 3) { - try { - await notifyAdmin( - 'retry_exhausted', - { - service: 'provision-consumer', - error: err.message, - context: `주문번호: ${order_id}\n사용자 ID: ${user_id}\n재시도 횟수: ${message.attempts}\n스택: ${err.stack || 'N/A'}`, - }, - { - telegram: { - sendMessage: (chatId: number, text: string) => - sendMessage(env.BOT_TOKEN, chatId, text) - }, - adminId: env.SERVER_ADMIN_ID || env.DEPOSIT_ADMIN_ID || '', - env, - } - ); - } catch (adminNotifyError) { - // Admin notification failed - log only - logger.error('관리자 알림 전송 실패', adminNotifyError as Error, { order_id }); - } - } - - // Retry (max 3 attempts → DLQ) - message.retry(); - } - } -} diff --git a/src/queue/provision-dlq.ts b/src/queue/provision-dlq.ts deleted file mode 100644 index 80aa870..0000000 --- a/src/queue/provision-dlq.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createLogger } from '../utils/logger'; -import { sendMessage } from '../telegram'; -import { notifyAdmin } from '../services/notification'; -import type { Env, ProvisionMessage } from '../types'; - -const logger = createLogger('provision-dlq'); - -/** - * Dead Letter Queue 핸들러 - * - * 최대 재시도 횟수를 초과한 서버 생성 작업 처리 - * - DB 상태를 'failed'로 업데이트 - * - 사용자에게 실패 알림 - * - 관리자에게 즉시 알림 - * - DLQ에서 메시지 제거 (무한 루프 방지) - */ -export async function handleProvisionDLQ( - batch: MessageBatch, - env: Env -): Promise { - logger.info('DLQ 배치 처리 시작', { messageCount: batch.messages.length }); - - for (const message of batch.messages) { - const { order_id, user_id, telegram_user_id, chat_id } = message.body; - - logger.error('서버 생성 최종 실패 (DLQ)', new Error('Max retries exceeded'), { - order_id, - user_id, - telegram_user_id, - attempts: message.attempts, - }); - - try { - // 1. DB 상태 업데이트 (failed) - const updateResult = await env.DB.prepare( - `UPDATE server_orders - SET status = 'failed', - error_message = '서버 생성 실패: 최대 재시도 횟수 초과', - updated_at = CURRENT_TIMESTAMP - WHERE id = ?` - ).bind(order_id).run(); - - if (!updateResult.success) { - throw new Error('DB 업데이트 실패'); - } - - logger.info('주문 상태 업데이트 완료', { order_id, status: 'failed' }); - - // 2. 잔액 환불 처리 (이미 차감되었는지 확인) - let balanceRefunded = false; - try { - // 주문 정보 조회 - const order = await env.DB.prepare( - `SELECT price_paid, user_id FROM server_orders WHERE id = ?` - ).bind(order_id).first<{ price_paid: number; user_id: number }>(); - - if (!order) { - throw new Error('주문 정보를 찾을 수 없습니다'); - } - - // 이미 잔액이 차감되었는지 확인 (거래 내역 검색) - const deduction = await env.DB.prepare( - `SELECT id FROM deposit_transactions - WHERE user_id = ? AND type = 'withdrawal' - AND description LIKE ?` - ).bind(order.user_id, `%order-${order_id}%`).first(); - - // 차감되었으면 환불 처리 - if (deduction) { - const refundResults = await env.DB.batch([ - env.DB.prepare( - 'UPDATE user_deposits SET balance = balance + ?, version = version + 1 WHERE user_id = ?' - ).bind(order.price_paid, order.user_id), - env.DB.prepare( - `INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) - VALUES (?, 'refund', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` - ).bind(order.user_id, order.price_paid, `서버 생성 실패 환불: order-${order_id}`), - ]); - - if (refundResults.every(r => r.success)) { - balanceRefunded = true; - logger.info('잔액 환불 완료', { - order_id, - user_id: order.user_id, - refund_amount: order.price_paid, - }); - } else { - logger.error('환불 트랜잭션 실패', new Error('Batch operation failed'), { - order_id, - user_id: order.user_id, - }); - } - } else { - logger.info('잔액 차감 내역 없음 (환불 불필요)', { order_id }); - } - } catch (refundError) { - logger.error('환불 처리 중 에러', refundError as Error, { order_id }); - // 환불 실패해도 계속 진행 (사용자/관리자 알림 필요) - } - - // 3. 사용자 알림 (환불 정보 포함) - await sendMessage( - env.BOT_TOKEN, - chat_id, - `❌ 서버 생성 실패 - -주문번호: #${order_id} - -일시적인 문제로 서버를 생성할 수 없습니다. - -${balanceRefunded ? '✅ 결제 금액이 환불되었습니다.' : '⚠️ 잔액은 차감되지 않았습니다.'} - -관리자가 확인 후 연락드리겠습니다.`, - { parse_mode: 'HTML' } - ); - - logger.info('사용자 알림 전송 완료', { chat_id, order_id, balanceRefunded }); - - // 4. 관리자 알림 - const adminId = env.SERVER_ADMIN_ID || env.DEPOSIT_ADMIN_ID; - if (adminId) { - await notifyAdmin( - 'api_error', - { - service: 'server-provision-dlq', - error: '서버 생성 최종 실패 (DLQ)', - context: `주문: #${order_id}\n사용자 ID: ${user_id}\nTelegram: ${telegram_user_id}\n재시도 횟수: ${message.attempts}`, - }, - { - telegram: { - sendMessage: (chatId: number, text: string) => - sendMessage(env.BOT_TOKEN, chatId, text), - }, - adminId, - env, - } - ); - - logger.info('관리자 알림 전송 완료', { adminId, order_id }); - } else { - logger.warn('관리자 ID 미설정 (알림 생략)', { order_id }); - } - - // 5. DLQ에서 제거 - message.ack(); - logger.info('DLQ 메시지 ack 완료', { order_id }); - - } catch (error) { - logger.error('DLQ 처리 중 에러', error as Error, { order_id, user_id }); - - // DLQ 처리 실패해도 ack (무한 루프 방지) - message.ack(); - logger.warn('에러 발생했지만 ack 처리 (무한 루프 방지)', { order_id }); - } - } - - logger.info('DLQ 배치 처리 완료', { processedCount: batch.messages.length }); -} diff --git a/src/routes/handlers/callback-handler.ts b/src/routes/handlers/callback-handler.ts index 3d2cccd..469be74 100644 --- a/src/routes/handlers/callback-handler.ts +++ b/src/routes/handlers/callback-handler.ts @@ -1,40 +1,8 @@ -import { answerCallbackQuery, editMessageText, sendMessage, sendMessageWithKeyboard } from '../../telegram'; +import { answerCallbackQuery, editMessageText } from '../../telegram'; import { UserService } from '../../services/user-service'; import { executeDomainRegister } from '../../domain-register'; -import { - getSessionForUser, - updateSession, - deleteSession, - createSession, - ServerOrderSessionData, -} from '../../utils/session'; -import { getServerSpec } from '../../services/cloud-spec-service'; -import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server'; -import { createLogger } from '../../utils/logger'; import type { Env, TelegramUpdate } from '../../types'; -const logger = createLogger('callback-handler'); - -/** - * Allowed OS images for server provisioning - */ -const ALLOWED_OS_IMAGES = ['ubuntu-22.04', 'ubuntu-24.04', 'debian-12', 'centos-stream-9']; - -/** - * Safely parse integer with range validation - * @param value - String to parse - * @param min - Minimum allowed value (inclusive) - * @param max - Maximum allowed value (inclusive) - * @returns Parsed integer or null if invalid/out of range - */ -function parseIntSafe(value: string, min: number, max: number): number | null { - const parsed = parseInt(value, 10); - if (isNaN(parsed) || parsed < min || parsed > max) { - return null; - } - return parsed; -} - /** * Callback Query 처리 (인라인 버튼 클릭) */ @@ -71,9 +39,10 @@ export async function handleCallbackQuery( } const domain = parts[1]; - const price = parseIntSafe(parts[2], 0, 10000000); // 0 ~ 10 million KRW + const priceStr = parts[2]; + const price = parseInt(priceStr, 10); - if (price === null) { + if (isNaN(price) || price < 0 || price > 10000000) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' }); return; } @@ -134,614 +103,6 @@ ${result.error} return; } - // ===== 세션 기반 서버 플로우 ===== - if (data.startsWith('srv:')) { - const parts = data.split(':'); - const sessionId = parts[1]; - const action = parts[2]; - - // 세션 조회 + 권한 검증 - const session = await getSessionForUser( - env.SESSION_KV, - sessionId, - user.id - ); - - if (!session) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { - text: '세션이 만료되었습니다. 다시 시작해주세요.' - }); - await editMessageText(env.BOT_TOKEN, chatId, messageId, - '⏰ 세션이 만료되었습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.' - ); - return; - } - - // select: 사양 선택 (추천 목록에서) - if (action === 'select') { - const index = parseInt(parts[3], 10); - const recs = session.data.recommendations; - - if (!recs || index < 0 || index >= recs.length) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' }); - return; - } - - const selected = recs[index]; - - // 세션 업데이트 - await updateSession(env.SESSION_KV, sessionId, { - step: 'spec_confirm', - plan: selected.plan, - region: selected.region, - provider: selected.provider, - recommendations: undefined // 선택 후 목록 삭제 - }); - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 조회 중...' }); - - // CLOUD_DB에서 상세 조회 - const spec = await getServerSpec( - env.CLOUD_DB, - selected.plan, - selected.region, - selected.provider - ); - - if (!spec) { - await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.'); - return; - } - - // 가격 정보 세션에 저장 - await updateSession(env.SESSION_KV, sessionId, { - priceKrw: spec.monthly_price_krw - }); - - const ramGB = (spec.memory_mb / 1024).toFixed(1); - const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유'; - - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `📦 서버 사양 확인 - -컴퓨팅 -• vCPU: ${spec.vcpu}개 -• RAM: ${ramGB}GB -• 스토리지: ${spec.storage_gb}GB SSD - -네트워크 -• 트래픽: ${spec.transfer_tb}TB/월 -• 대역폭: ${networkSpeed} - -요금 -• 월 ${spec.monthly_price_krw.toLocaleString()}원 - -이 사양으로 진행하시겠습니까?`, - { - reply_markup: { - inline_keyboard: [ - [{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }], - [ - { text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` }, - { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } - ] - ] - } - } - ); - return; - } - - // os_list: OS 선택 화면 - if (action === 'os_list') { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택' }); - - await updateSession(env.SESSION_KV, sessionId, { - step: 'os_select' - }); - - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `🖥️ OS 선택\n\n서버에 설치할 운영체제를 선택하세요:`, - { - reply_markup: { - inline_keyboard: [ - [{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }], - [{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }], - [{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }], - [{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }], - [ - { text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` }, - { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } - ] - ] - } - } - ); - return; - } - - // os: OS 선택 완료 → 주문 생성 + 최종 확인 - if (action === 'os') { - const osImage = parts[3]; - - // Validation: Check if OS image is allowed - if (!ALLOWED_OS_IMAGES.includes(osImage)) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '지원하지 않는 OS입니다.' }); - return; - } - - await updateSession(env.SESSION_KV, sessionId, { - step: 'final_confirm', - image: osImage - }); - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 생성 중...' }); - - const { plan, region, provider } = session.data; - - // Validation: Check if required session data exists - if (!plan || !region || !provider) { - logger.error('세션 데이터 불완전', undefined, { sessionId, plan, region, provider }); - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '세션이 만료되었습니다. 다시 시작해주세요.' }); - await deleteSession(env.SESSION_KV, sessionId); - return; - } - - // DB에서 사양 조회 - const spec = await getServerSpec( - env.CLOUD_DB, - plan!, - region!, - provider! - ); - - if (!spec) { - await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.'); - return; - } - - // 잔액 확인 - const deposit = await env.DB.prepare( - "SELECT balance FROM user_deposits WHERE user_id = ?" - ).bind(user.id).first<{ balance: number }>(); - const balance = deposit?.balance || 0; - - if (balance < spec.monthly_price_krw) { - const shortage = spec.monthly_price_krw - balance; - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `❌ 잔액 부족 - -• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원 -• 현재 잔액: ${balance.toLocaleString()}원 -• 부족 금액: ${shortage.toLocaleString()}원 - -💳 입금 계좌 -하나은행 427-910018-27104 (주식회사 아이언클래드) -입금 후 다시 시도해주세요.` - ); - return; - } - - // 주문 생성 - const label = `server-${Date.now()}`; - const orderResult = await env.DB.prepare(` - INSERT INTO server_orders (user_id, spec_id, status, label, region, image, price_paid, billing_type, created_at) - VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly', datetime('now')) - `).bind(user.id, spec.pricing_id, label, region, osImage, spec.monthly_price_krw).run(); - - const orderId = orderResult.meta?.last_row_id; - - if (!orderId) { - await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 주문 생성에 실패했습니다.'); - return; - } - - await updateSession(env.SESSION_KV, sessionId, { - orderId: orderId - }); - - // OS 이름 변환 - const ramGB = (spec.memory_mb / 1024).toFixed(1); - const specStr = `${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD`; - - // 최종 확인 화면 - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `✅ 최종 확인 - -• 사양: ${specStr} -• OS: ${getOSDisplayName(osImage)} -• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 -• 현재 잔액: ${balance.toLocaleString()}원 - -💡 요금 안내 -• 월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다. -• 예: 10일 사용 후 해지 → (시간당 요금 × 사용 시간) 차감 후 잔액 환불`, - { - reply_markup: { - inline_keyboard: [ - [{ text: '✅ 서버 생성', callback_data: `srv:${sessionId}:confirm` }], - [ - { text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_os` }, - { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } - ] - ] - } - } - ); - return; - } - - // confirm: 서버 생성 요청 (Queue 전송) - if (action === 'confirm') { - const { orderId } = session.data; - if (!orderId) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 정보가 없습니다.' }); - return; - } - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 접수 중...' }); - - // Queue에 메시지 전송 (즉시 반환) - await env.SERVER_PROVISION_QUEUE.send({ - order_id: orderId, - user_id: user.id, - telegram_user_id: telegramUserId, - chat_id: chatId, - timestamp: Date.now(), - }); - - // 사용자에게 즉시 응답 - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `📋 서버 생성 주문 접수 완료! - -주문번호: #${orderId} - -⏳ 서버를 생성하고 있습니다. (1-3분 소요) -완료되면 알림을 보내드립니다. - -💡 이 메시지를 닫아도 괜찮습니다.`, - { parse_mode: 'HTML' } - ); - - // 세션 삭제 - await deleteSession(env.SESSION_KV, sessionId); - return; - } - - // cancel: 취소 - if (action === 'cancel') { - const { orderId } = session.data; - - // pending 주문 있으면 삭제 - if (orderId) { - await env.DB.prepare( - "UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'" - ).bind(orderId, user.id).run(); - } - - // 세션 삭제 - await deleteSession(env.SESSION_KV, sessionId); - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); - await editMessageText(env.BOT_TOKEN, chatId, messageId, - '❌ 서버 선택이 취소되었습니다.\n\n다시 추천받으시려면 "서버 추천해줘"라고 말씀해주세요.' - ); - return; - } - - // reselect: 다른 사양 선택 (다시 추천 API 호출) - if (action === 'reselect') { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '다시 추천받는 중...' }); - await editMessageText(env.BOT_TOKEN, chatId, messageId, '🔄 다시 추천받는 중...'); - - // 기존 세션 삭제 - await deleteSession(env.SESSION_KV, sessionId); - - try { - // SERVER_RECOMMEND 서비스로 기본 추천 요청 - const requestBody = { - tech_stack: ['nginx'], - expected_users: 100, - use_case: 'general purpose server', - lang: 'ko' - }; - - const response = env.SERVER_RECOMMEND - ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }) - : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const apiResult = await response.json() as { - recommendations?: Array<{ - server: { - instance_id: string; - region_code: string; - provider_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - monthly_price: number; - }; - score: number; - }>; - }; - - if (!apiResult.recommendations || apiResult.recommendations.length === 0) { - throw new Error('No recommendations'); - } - - // 상위 5개 추천 - const topRecs = apiResult.recommendations.slice(0, 5); - - let responseText = `🎯 범용 서버 추천\n\n`; - topRecs.forEach((rec, index) => { - const server = rec.server; - const ramGB = (server.memory_mb / 1024).toFixed(1); - const priceKrw = Math.round(server.monthly_price); - const regionDisplay = getRegionDisplay(server.region_code); - responseText += `${NUM_EMOJIS[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; - responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 AI 점수: ${rec.score}/100\n\n`; - }); - responseText += `👆 서버를 신청하려면 아래 버튼을 선택하세요\n\n💡 다른 조건을 원하시면 "웹서버용 서버 추천" 형식으로 말씀해주세요.`; - - // 새 세션 생성 - const newSessionId = await createSession( - env.SESSION_KV, - user.id, - 'server_order', - { - recommendations: topRecs.map(rec => ({ - plan: rec.server.instance_id, - region: rec.server.region_code, - provider: rec.server.provider_name.toLowerCase() - })) - }, - 'recommend' - ); - - // 버튼 생성 - const buttons = topRecs.map((_, index) => ({ - text: `${index + 1}번 선택`, - callback_data: `srv:${newSessionId}:select:${index}` - })); - - const keyboard = []; - if (buttons.length > 0) keyboard.push(buttons.slice(0, 3)); - if (buttons.length > 3) keyboard.push(buttons.slice(3)); - - await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, keyboard); - } catch (error) { - console.error('[srv:reselect] 추천 API 오류:', error); - await sendMessage( - env.BOT_TOKEN, - chatId, - '❌ 추천을 다시 받는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 말씀해주세요.' - ); - } - return; - } - - // back_spec: 사양 확인으로 뒤로 - if (action === 'back_spec') { - const { plan, region, provider } = session.data; - - await updateSession(env.SESSION_KV, sessionId, { - step: 'spec_confirm', - image: undefined - }); - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 확인으로 이동' }); - - // 사양 상세 다시 조회 및 표시 - const spec = await getServerSpec( - env.CLOUD_DB, - plan!, - region!, - provider! - ); - - if (!spec) { - await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.'); - return; - } - - const ramGB = (spec.memory_mb / 1024).toFixed(1); - const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유'; - - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `📦 서버 사양 확인 - -컴퓨팅 -• vCPU: ${spec.vcpu}개 -• RAM: ${ramGB}GB -• 스토리지: ${spec.storage_gb}GB SSD - -네트워크 -• 트래픽: ${spec.transfer_tb}TB/월 -• 대역폭: ${networkSpeed} - -요금 -• 월 ${spec.monthly_price_krw.toLocaleString()}원 - -이 사양으로 진행하시겠습니까?`, - { - reply_markup: { - inline_keyboard: [ - [{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }], - [ - { text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` }, - { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } - ] - ] - } - } - ); - return; - } - - // back_os: OS 선택으로 뒤로 - if (action === 'back_os') { - const { orderId } = session.data; - - // pending 주문 삭제 - if (orderId) { - await env.DB.prepare( - "DELETE FROM server_orders WHERE id = ? AND user_id = ? AND status = 'pending'" - ).bind(orderId, user.id).run(); - } - - await updateSession(env.SESSION_KV, sessionId, { - step: 'os_select', - image: undefined, - orderId: undefined - }); - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택으로 이동' }); - - // OS 선택 화면 표시 - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `🖥️ OS 선택\n\n서버에 설치할 운영체제를 선택하세요:`, - { - reply_markup: { - inline_keyboard: [ - [{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }], - [{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }], - [{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }], - [{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }], - [ - { text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` }, - { text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` } - ] - ] - } - } - ); - return; - } - - // 알 수 없는 action - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' }); - return; - } - - // 서버 주문 확인 (레거시 - Queue 기반으로 전환) - if (data.startsWith('server_order:')) { - const parts = data.split(':'); - if (parts.length !== 2) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); - return; - } - - const orderId = parseIntSafe(parts[1], 1, 2147483647); // Max INT - - if (orderId === null) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' }); - return; - } - - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 접수 중...' }); - - // Queue에 메시지 전송 (즉시 반환) - await env.SERVER_PROVISION_QUEUE.send({ - order_id: orderId, - user_id: user.id, - telegram_user_id: telegramUserId, - chat_id: chatId, - timestamp: Date.now(), - }); - - // 사용자에게 즉시 응답 - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `📋 서버 생성 주문 접수 완료! - -주문번호: #${orderId} - -⏳ 서버를 생성하고 있습니다. (1-3분 소요) -완료되면 알림을 보내드립니다. - -💡 이 메시지를 닫아도 괜찮습니다.`, - { parse_mode: 'HTML' } - ); - - return; - } - - // 서버 주문 취소 - if (data.startsWith('server_cancel:')) { - const parts = data.split(':'); - if (parts.length !== 2) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); - return; - } - - const orderId = parseIntSafe(parts[1], 1, 2147483647); - - if (orderId === null) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' }); - return; - } - - // 주문 취소 처리 (DB에서 status를 cancelled로 변경) - const cancelResult = await env.DB.prepare( - "UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'" - ).bind(orderId, user.id).run(); - - if (cancelResult.success && cancelResult.meta?.changes && cancelResult.meta.changes > 0) { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - '❌ 서버 주문이 취소되었습니다.' - ); - } else { - await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소 실패 (이미 처리됨)' }); - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - '⚠️ 주문 취소에 실패했습니다. (이미 처리되었거나 권한이 없습니다.)' - ); - } - return; - } - - // ===== 이하 기존 핸들러는 레거시 주문 전용 (새로운 세션 기반 플로우는 srv:로 시작) ===== - + // 알 수 없는 callback data await answerCallbackQuery(env.BOT_TOKEN, queryId); } diff --git a/src/routes/handlers/message-handler.ts b/src/routes/handlers/message-handler.ts index 1ced688..aaa6999 100644 --- a/src/routes/handlers/message-handler.ts +++ b/src/routes/handlers/message-handler.ts @@ -4,15 +4,6 @@ import { handleCommand } from '../../commands'; import { UserService } from '../../services/user-service'; import { ConversationService } from '../../services/conversation-service'; import { ERROR_MESSAGES } from '../../constants/messages'; -import { - createSession, - updateSession, - deleteSession, - getUserActiveSession, - ServerOrderSessionData, -} from '../../utils/session'; -import { getServerSpec } from '../../services/cloud-spec-service'; -import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server'; import type { Env, TelegramUpdate } from '../../types'; /** @@ -63,458 +54,7 @@ export async function handleMessage( } try { - // 4. 세션 기반 대화형 서버 주문 플로우 처리 - const serverSession = await getUserActiveSession( - env.SESSION_KV, - userId, - 'server_order' - ); - - if (serverSession) { - const { sessionId, session } = serverSession; - const { step } = session; - const lowerText = text.toLowerCase().trim(); - - // 취소 패턴 (모든 단계에서) - if (/^(취소|그만|중단|cancel|stop)/.test(lowerText)) { - // pending 주문 있으면 취소 - if (session.data.orderId) { - await env.DB.prepare( - "UPDATE server_orders SET status = 'cancelled' WHERE id = ? AND user_id = ? AND status = 'pending'" - ).bind(session.data.orderId, userId).run(); - } - await deleteSession(env.SESSION_KV, sessionId); - await sendMessage(env.BOT_TOKEN, chatId, - '❌ 서버 주문이 취소되었습니다.\n\n다시 시작하려면 "서버 추천해줘"라고 말씀해주세요.' - ); - return; - } - - // Step 1: recommend - 추천 목록에서 선택 - if (step === 'recommend' && session.data.recommendations) { - // 숫자 패턴: "1", "1번", "첫번째", "첫 번째" - const numPatterns: Record = { - '1': 0, '1번': 0, '첫번째': 0, '첫 번째': 0, '일번': 0, - '2': 1, '2번': 1, '두번째': 1, '두 번째': 1, '이번': 1, - '3': 2, '3번': 2, '세번째': 2, '세 번째': 2, '삼번': 2, - '4': 3, '4번': 3, '네번째': 3, '네 번째': 3, '사번': 3, - '5': 4, '5번': 4, '다섯번째': 4, '다섯 번째': 4, '오번': 4, - }; - - const matchedIndex = numPatterns[lowerText]; - if (matchedIndex !== undefined && matchedIndex < session.data.recommendations.length) { - const selected = session.data.recommendations[matchedIndex]; - - // 세션 업데이트 - await updateSession(env.SESSION_KV, sessionId, { - step: 'spec_confirm', - plan: selected.plan, - region: selected.region, - provider: selected.provider, - recommendations: undefined - }); - - // 사양 조회 - const spec = await getServerSpec( - env.CLOUD_DB, - selected.plan, - selected.region, - selected.provider - ); - - if (!spec) { - await sendMessage(env.BOT_TOKEN, chatId, '❌ 사양을 찾을 수 없습니다. 다시 시도해주세요.'); - return; - } - - await updateSession(env.SESSION_KV, sessionId, { - priceKrw: spec.monthly_price_krw - }); - - const ramGB = (spec.memory_mb / 1024).toFixed(1); - await sendMessage(env.BOT_TOKEN, chatId, - `📦 ${matchedIndex + 1}번 사양 선택 - -컴퓨팅 -• vCPU: ${spec.vcpu}개 -• RAM: ${ramGB}GB -• 스토리지: ${spec.storage_gb}GB SSD -• 트래픽: ${spec.transfer_tb}TB/월 - -요금 -• 월 ${spec.monthly_price_krw.toLocaleString()}원 - -🖥️ OS를 선택해주세요: -• "우분투" 또는 "ubuntu 22" -• "우분투 24" 또는 "ubuntu 24" -• "데비안" 또는 "debian" -• "센토스" 또는 "centos" - -💡 "뒤로"로 추천 목록으로, "다시"로 새 추천, "취소"로 중단할 수 있습니다.` - ); - return; - } - } - - // Step 2: spec_confirm - OS 선택 - if (step === 'spec_confirm') { - // "뒤로" 패턴 - 추천 목록으로 돌아가기 - if (/^(뒤로|back)$/.test(lowerText)) { - await sendMessage(env.BOT_TOKEN, chatId, '🔄 추천 목록을 다시 불러오는 중...'); - - try { - const requestBody = { - tech_stack: ['nginx'], - expected_users: 100, - use_case: 'general purpose server', - lang: 'ko' - }; - - const response = env.SERVER_RECOMMEND - ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }) - : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) throw new Error(`API error: ${response.status}`); - - const result = await response.json() as { - recommendations?: Array<{ - server: { - instance_id: string; - region_code: string; - provider_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - monthly_price: number; - }; - score: number; - }>; - }; - - if (!result.recommendations || result.recommendations.length === 0) { - throw new Error('No recommendations'); - } - - const topRecs = result.recommendations.slice(0, 5); - - // 세션 업데이트 (step: recommend, recommendations 다시 저장) - await updateSession(env.SESSION_KV, sessionId, { - step: 'recommend', - plan: undefined, - region: undefined, - provider: undefined, - priceKrw: undefined, - recommendations: topRecs.map(rec => ({ - plan: rec.server.instance_id, - region: rec.server.region_code, - provider: rec.server.provider_name.toLowerCase() - })) - }); - - let responseText = `🎯 서버 추천\n\n`; - topRecs.forEach((rec, index) => { - const server = rec.server; - const ramGB = (server.memory_mb / 1024).toFixed(1); - const priceKrw = Math.round(server.monthly_price); - const regionDisplay = getRegionDisplay(server.region_code); - responseText += `${NUM_EMOJIS[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; - responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`; - }); - responseText += `💬 원하시는 번호를 입력해주세요\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`; - - await sendMessage(env.BOT_TOKEN, chatId, responseText); - } catch (error) { - console.error('[back] 추천 API 오류:', error); - await sendMessage(env.BOT_TOKEN, chatId, - '❌ 추천 목록을 불러오는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.' - ); - await deleteSession(env.SESSION_KV, sessionId); - } - return; - } - - // "다시" 패턴 - 새로 추천받기 (기존 세션 유지하면서 새 추천) - if (/^(다시|다른)/.test(lowerText)) { - await sendMessage(env.BOT_TOKEN, chatId, '🔄 새로운 추천을 받는 중...'); - - try { - const requestBody = { - tech_stack: ['nginx'], - expected_users: 100, - use_case: 'general purpose server', - lang: 'ko' - }; - - const response = env.SERVER_RECOMMEND - ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }) - : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) throw new Error(`API error: ${response.status}`); - - const result = await response.json() as { - recommendations?: Array<{ - server: { - instance_id: string; - region_code: string; - provider_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - monthly_price: number; - }; - score: number; - }>; - }; - - if (!result.recommendations || result.recommendations.length === 0) { - throw new Error('No recommendations'); - } - - const topRecs = result.recommendations.slice(0, 5); - - // 세션 업데이트 (새 추천으로 교체) - await updateSession(env.SESSION_KV, sessionId, { - step: 'recommend', - plan: undefined, - region: undefined, - provider: undefined, - priceKrw: undefined, - recommendations: topRecs.map(rec => ({ - plan: rec.server.instance_id, - region: rec.server.region_code, - provider: rec.server.provider_name.toLowerCase() - })) - }); - - let responseText = `🎯 새로운 서버 추천\n\n`; - topRecs.forEach((rec, index) => { - const server = rec.server; - const ramGB = (server.memory_mb / 1024).toFixed(1); - const priceKrw = Math.round(server.monthly_price); - const regionDisplay = getRegionDisplay(server.region_code); - responseText += `${NUM_EMOJIS[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; - responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`; - }); - responseText += `💬 원하시는 번호를 입력해주세요\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`; - - await sendMessage(env.BOT_TOKEN, chatId, responseText); - } catch (error) { - console.error('[다시] 추천 API 오류:', error); - await sendMessage(env.BOT_TOKEN, chatId, - '❌ 새 추천을 받는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.' - ); - await deleteSession(env.SESSION_KV, sessionId); - } - return; - } - - // OS 패턴 매칭 - let osImage: string | null = null; - if (/우분투\s*22|ubuntu\s*22|우분투$|ubuntu$/.test(lowerText)) { - osImage = 'ubuntu-22.04'; - } else if (/우분투\s*24|ubuntu\s*24/.test(lowerText)) { - osImage = 'ubuntu-24.04'; - } else if (/데비안|debian/.test(lowerText)) { - osImage = 'debian-12'; - } else if (/센토스|centos/.test(lowerText)) { - osImage = 'centos-stream-9'; - } - - if (osImage) { - const { plan, region, provider } = session.data; - - // 사양 재조회 - const spec = await getServerSpec( - env.CLOUD_DB, - plan!, - region!, - provider! - ); - - if (!spec) { - await sendMessage(env.BOT_TOKEN, chatId, '❌ 사양 정보를 찾을 수 없습니다.'); - return; - } - - // 잔액 확인 - const deposit = await env.DB.prepare( - "SELECT balance FROM user_deposits WHERE user_id = ?" - ).bind(userId).first<{ balance: number }>(); - const balance = deposit?.balance || 0; - - if (balance < spec.monthly_price_krw) { - const shortage = spec.monthly_price_krw - balance; - await sendMessage(env.BOT_TOKEN, chatId, - `❌ 잔액 부족 - -• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원 -• 현재 잔액: ${balance.toLocaleString()}원 -• 부족 금액: ${shortage.toLocaleString()}원 - -💳 입금 계좌 -하나은행 427-910018-27104 (주식회사 아이언클래드) - -입금 후 다시 시도해주세요.` - ); - return; - } - - // 주문 생성 - const label = `server-${Date.now()}`; - const orderResult = await env.DB.prepare(` - INSERT INTO server_orders (user_id, spec_id, status, label, region, image, price_paid, billing_type, created_at) - VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly', datetime('now')) - `).bind(userId, spec.pricing_id, label, region, osImage, spec.monthly_price_krw).run(); - - const orderId = orderResult.meta?.last_row_id; - - await updateSession(env.SESSION_KV, sessionId, { - step: 'final_confirm', - image: osImage, - orderId: orderId - }); - - - const ramGB = (spec.memory_mb / 1024).toFixed(1); - await sendMessage(env.BOT_TOKEN, chatId, - `✅ 최종 확인 - -• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD -• OS: ${getOSDisplayName(osImage)} -• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 -• 현재 잔액: ${balance.toLocaleString()}원 - -⚠️ 요금 안내 -월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다. - -🚀 서버를 생성하시려면 "확인" 또는 "생성"이라고 입력하세요. -❌ 취소하시려면 "취소"라고 입력하세요.` - ); - return; - } - } - - // Step 3: final_confirm - 최종 확인 - if (step === 'final_confirm') { - // "뒤로" 패턴 - OS 선택으로 - if (/^(뒤로|back|os)/.test(lowerText)) { - // pending 주문 삭제 - if (session.data.orderId) { - await env.DB.prepare( - "DELETE FROM server_orders WHERE id = ? AND user_id = ? AND status = 'pending'" - ).bind(session.data.orderId, userId).run(); - } - - await updateSession(env.SESSION_KV, sessionId, { - step: 'spec_confirm', - image: undefined, - orderId: undefined - }); - - // 선택된 사양 정보 표시 - const { priceKrw } = session.data; - const priceInfo = priceKrw ? `${priceKrw.toLocaleString()}원/월` : '?'; - - await sendMessage(env.BOT_TOKEN, chatId, - `🖥️ OS를 다시 선택해주세요: - -현재 선택된 사양: ${priceInfo} - -• "우분투" 또는 "ubuntu 22" -• "우분투 24" 또는 "ubuntu 24" -• "데비안" 또는 "debian" -• "센토스" 또는 "centos" - -💡 "뒤로"로 사양 선택으로 돌아가거나, "취소"로 중단할 수 있습니다.` - ); - return; - } - - // 확인 패턴 - 서버 생성 - if (/^(확인|진행|생성|네|예|ok|yes|confirm)/.test(lowerText)) { - const { orderId } = session.data; - - if (!orderId) { - await sendMessage(env.BOT_TOKEN, chatId, '❌ 주문 정보가 없습니다. 다시 시작해주세요.'); - await deleteSession(env.SESSION_KV, sessionId); - return; - } - - await sendMessage(env.BOT_TOKEN, chatId, '⏳ 서버를 생성하고 있습니다... (1-3분 소요)'); - - // 서버 생성 실행은 webhook.ts에서 executeServerProvision 임포트 - const { executeServerProvision } = await import('../../server-provision'); - const result = await executeServerProvision(env, userId, telegramUserId, orderId); - - // 세션 삭제 - await deleteSession(env.SESSION_KV, sessionId); - - if (result.success) { - await sendMessage(env.BOT_TOKEN, chatId, - `✅ 서버 생성 완료! - -• IP 주소: ${result.ip_address} -• Root 비밀번호: ${result.root_password} - -📌 접속 방법 -ssh root@${result.ip_address} - -⚠️ 보안 권고 -1. 즉시 비밀번호를 변경하세요: passwd -2. SSH 키 인증 설정을 권장합니다. - -🎉 서버가 성공적으로 생성되었습니다!` - ); - } else { - await sendMessage(env.BOT_TOKEN, chatId, - `❌ 서버 생성 실패 - -${result.error} - -다시 시도하시려면 "서버 추천해줘"라고 말씀해주세요.` - ); - } - return; - } - } - - // 세션은 있지만 매칭되는 입력이 아닌 경우 - 힌트 제공 - // (AI 응답으로 넘어가도록 여기서 return 하지 않음) - // 단, 명확한 서버 관련 질문이면 힌트 제공 - if (/서버|사양|os|운영체제/.test(lowerText) && !/추천|알려/.test(lowerText)) { - let hint = ''; - if (step === 'recommend') { - hint = '💡 추천 목록에서 번호를 선택해주세요. (예: "1번", "두번째")'; - } else if (step === 'spec_confirm') { - hint = '💡 OS를 선택해주세요. (예: "우분투", "debian")'; - } else if (step === 'final_confirm') { - hint = '💡 "확인"으로 서버를 생성하거나, "취소"로 주문을 취소할 수 있습니다.'; - } - if (hint) { - await sendMessage(env.BOT_TOKEN, chatId, hint); - return; - } - } - } - // === 세션 기반 대화형 플로우 처리 끝 === - - // 5. 명령어 처리 + // 4. 명령어 처리 if (text.startsWith('/')) { const [command, ...argParts] = text.split(' '); const args = argParts.join(' '); @@ -560,39 +100,6 @@ ${result.error} { text: '❌ 취소', callback_data: 'domain_cancel' } ] ]); - } else if (result.keyboardData.type === 'server_order') { - const { order_id } = result.keyboardData; - const confirmData = `server_order:${order_id}`; - const cancelData = `server_cancel:${order_id}`; - - await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [ - [ - { text: '✅ 생성하기', callback_data: confirmData }, - { text: '❌ 취소', callback_data: cancelData } - ] - ]); - } else if (result.keyboardData.type === 'server_recommend') { - const { specs } = result.keyboardData; - - // 세션 생성 (기존 세션 있으면 덮어씀) - 추천 목록 저장 - await createSession( - env.SESSION_KV, - userId, - 'server_order', - { - recommendations: specs.map(spec => ({ - plan: spec.plan, - region: spec.region, - provider: spec.provider - })) - }, - 'recommend' - ); - - // 대화형 안내 추가 (버튼 없이 메시지만) - const guideText = `\n\n💬 원하시는 번호를 입력해주세요\n예: "1번" 또는 "첫번째"\n\n💡 "취소"로 언제든 중단할 수 있습니다.`; - - await sendMessage(env.BOT_TOKEN, chatId, finalResponse + guideText); } else { // TypeScript exhaustiveness check - should never reach here console.warn('[Webhook] Unknown keyboard type:', (result.keyboardData as { type: string }).type); diff --git a/src/server-provision.ts b/src/server-provision.ts deleted file mode 100644 index 820ee39..0000000 --- a/src/server-provision.ts +++ /dev/null @@ -1,493 +0,0 @@ -/** - * Server Provisioning Orchestrator - * - * Purpose: Execute actual server creation after user confirmation - * - * Flow: - * 1. Fetch order from DB (server_orders) - * 2. Fetch spec from CLOUD_DB (pricing + instance_types + providers + regions) - * 3. Validate status (only 'pending' orders) - * 4. Re-check balance (Optimistic Locking) - * 5. Update status: provisioning - * 6. Call Cloud API (Linode or Vultr) - * 7. Deduct balance + record transaction (Optimistic Locking, db.batch) - * 8. Update order (status='active', IP addresses, provider_instance_id) - * 9. Add to user_servers table - * 10. Return result - * - * On failure: - * - Set status='failed', error_message - * - Do NOT deduct balance (balance deduction happens only after successful provisioning) - * - * DB Architecture: - * - env.DB (telegram-conversations): server_orders, user_servers, user_deposits - * - env.CLOUD_DB (cloud-instances-db): pricing, instance_types, providers, regions - */ - -import { createLogger } from './utils/logger'; -import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock'; -import { createInstance as createLinodeInstance } from './services/linode-api'; -import { createInstance as createVultrInstance } from './services/vultr-api'; -import { notifyAdmin } from './services/notification'; -import { sendMessage } from './telegram'; -import type { - Env, - LinodeInstance, - VultrInstance, -} from './types'; - -const logger = createLogger('server-provision'); - -export interface ProvisionResult { - success: boolean; - retryable?: boolean; // false = 재시도 금지 (인스턴스 이미 생성됨 등) - order_id?: number; - instance_id?: string; - ip_address?: string; - root_password?: string; // Plain text (shown only once) - region?: string; // Region name (한글) - plan_label?: string; - error?: string; -} - -// Order row from DB -interface OrderRow { - id: number; - user_id: number; - spec_id: number; // pricing.id from CLOUD_DB - status: string; - region: string; // region_code - price_paid: number; -} - -// Spec info from CLOUD_DB -interface SpecInfo { - instance_id: string; // provider's plan ID (e.g., vc2-1c-1gb) - instance_name: string; // display name - vcpu: number; - memory_mb: number; - storage_gb: number; - region_code: string; - region_name: string; - provider_id: number; - provider_name: string; -} - -/** - * Execute server provisioning after user confirmation - * - * @param env - Environment variables (API keys, DB) - * @param userId - User ID (for ownership check) - * @param telegramUserId - Telegram User ID (for logging) - * @param orderId - Server order ID - * @returns ProvisionResult - */ -export async function executeServerProvision( - env: Env, - userId: number, - telegramUserId: string, - orderId: number -): Promise { - try { - // 1. Fetch order from DB (server_orders only) - const orderRow = await env.DB.prepare( - `SELECT id, user_id, spec_id, status, region, price_paid - FROM server_orders - WHERE id = ?` - ).bind(orderId).first(); - - if (!orderRow) { - logger.warn('Order not found', { orderId }); - return { success: false, retryable: false, error: '주문을 찾을 수 없습니다.' }; - } - - // 2. Validate ownership - if (orderRow.user_id !== userId) { - logger.warn('Order ownership mismatch', { - orderId, - userId, - orderUserId: orderRow.user_id, - }); - return { success: false, retryable: false, error: '본인의 주문만 처리할 수 있습니다.' }; - } - - // 3. Validate status (only 'pending' orders can be provisioned) - if (orderRow.status !== 'pending') { - logger.warn('Order status not pending', { - orderId, - status: orderRow.status, - }); - return { - success: false, - retryable: false, - error: `이미 처리된 주문입니다. (상태: ${orderRow.status})`, - }; - } - - // 4. Fetch spec info from CLOUD_DB - if (!env.CLOUD_DB) { - logger.error('CLOUD_DB not available', undefined, { orderId }); - return { success: false, retryable: true, error: '서버 사양 데이터베이스에 접근할 수 없습니다.' }; - } - - const specInfo = await env.CLOUD_DB.prepare( - `SELECT - it.instance_id, - it.instance_name, - it.vcpu, - it.memory_mb, - it.storage_gb, - r.region_code, - r.region_name, - prov.id as provider_id, - prov.name as provider_name - FROM pricing p - JOIN instance_types it ON p.instance_type_id = it.id - JOIN regions r ON p.region_id = r.id - JOIN providers prov ON it.provider_id = prov.id - WHERE p.id = ?` - ).bind(orderRow.spec_id).first(); - - if (!specInfo) { - logger.warn('Spec not found in CLOUD_DB', { specId: orderRow.spec_id }); - return { success: false, retryable: false, error: '서버 사양을 찾을 수 없습니다.' }; - } - - // 5. Re-check balance (security measure) - const balanceRow = await env.DB.prepare( - 'SELECT balance FROM user_deposits WHERE user_id = ?' - ).bind(userId).first<{ balance: number }>(); - - const currentBalance = balanceRow?.balance || 0; - if (currentBalance < orderRow.price_paid) { - logger.warn('Insufficient balance on provision', { - orderId, - userId, - currentBalance, - requiredAmount: orderRow.price_paid, - }); - return { - success: false, - retryable: false, - error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${orderRow.price_paid.toLocaleString()}원)`, - }; - } - - // 6. Update status: provisioning - const statusUpdate = await env.DB.prepare( - "UPDATE server_orders SET status = 'provisioning', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" - ).bind(orderId).run(); - - if (!statusUpdate.success || statusUpdate.meta.changes === 0) { - logger.error('Failed to update order status to provisioning', undefined, { - orderId, - updateResult: statusUpdate, - }); - return { success: false, retryable: true, error: '주문 상태 업데이트에 실패했습니다.' }; - } - - logger.info('Order status updated to provisioning', { - orderId, - provider: specInfo.provider_name, - plan: specInfo.instance_name, - region: orderRow.region, - }); - - // 7. Generate secure root password (20 chars) - const rootPassword = generateSecurePassword(); - - // 8. Call Cloud API (Linode or Vultr) - let instanceId: string | number; - let ipAddress: string; - - // Generate label from order ID and instance name - const instanceLabel = `order-${orderId}`; - - try { - if (specInfo.provider_name === 'linode') { - // Linode API - const linodeInstance: LinodeInstance = await createLinodeInstance( - { - type: specInfo.instance_id, - region: orderRow.region, - image: 'linode/ubuntu22.04', - root_pass: rootPassword, - label: instanceLabel, - }, - env - ); - - instanceId = linodeInstance.id; - ipAddress = linodeInstance.ipv4[0] || ''; - - logger.info('Linode instance created', { - orderId, - instanceId, - ipAddress, - label: linodeInstance.label, - }); - - } else if (specInfo.provider_name === 'vultr') { - // Vultr API - const vultrInstance: VultrInstance = await createVultrInstance( - { - plan: specInfo.instance_id, - region: orderRow.region, - os_id: 2136, // Ubuntu 24.04 LTS - label: instanceLabel, - hostname: instanceLabel, - }, - env - ); - - instanceId = vultrInstance.id; - ipAddress = vultrInstance.main_ip || ''; - - logger.info('Vultr instance created', { - orderId, - instanceId, - ipAddress, - label: vultrInstance.label, - }); - - } else { - throw new Error(`Unsupported provider: ${specInfo.provider_name}`); - } - - } catch (apiError) { - // Cloud API call failed - logger.error('Cloud API call failed', apiError as Error, { - orderId, - provider: specInfo.provider_name, - region: orderRow.region, - }); - - // Set status to 'failed' - await env.DB.prepare( - "UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" - ).bind( - `Cloud API 호출 실패: ${String(apiError)}`, - orderId - ).run(); - - return { - success: false, - retryable: true, - error: `서버 생성에 실패했습니다. 관리자에게 문의하세요. (주문번호: #${orderId})`, - }; - } - - // 9. Deduct balance + record transaction (Optimistic Locking) - try { - await executeWithOptimisticLock(env.DB, async (attempt) => { - // 9-1. Get current version - const current = await env.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'); - } - - // Double-check balance again (within lock) - if (current.balance < orderRow.price_paid) { - throw new Error('Insufficient balance during lock'); - } - - // 9-2. Update balance with version check - const balanceUpdate = await env.DB.prepare( - 'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' - ).bind(orderRow.price_paid, userId, current.version).run(); - - if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) { - throw new OptimisticLockError('Version mismatch on balance update'); - } - - // 9-3. Record withdrawal transaction - const txInsert = await env.DB.prepare( - `INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) - VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` - ).bind( - userId, - orderRow.price_paid, - `서버 프로비저닝: ${instanceLabel} (${specInfo.instance_name})` - ).run(); - - if (!txInsert.success) { - logger.error('Failed to insert withdrawal transaction', undefined, { - orderId, - userId, - amount: orderRow.price_paid, - attempt, - }); - throw new Error('Transaction insert failed'); - } - - logger.info('Balance deducted successfully', { - orderId, - userId, - amount: orderRow.price_paid, - newBalance: current.balance - orderRow.price_paid, - attempt, - }); - - return true; - }); - - } catch (lockError) { - // Balance deduction failed - instance already created, mark as failed for manual cleanup - logger.error('Balance deduction failed after instance creation', lockError as Error, { - orderId, - instanceId, - provider: specInfo.provider_name, - }); - - // 1. 관리자에게 즉시 알림 전송 (비용 누수 방지) - try { - await notifyAdmin( - 'api_error', - { - service: 'server-provision', - error: '인스턴스 생성 완료 후 잔액 차감 실패', - context: `주문번호: ${orderId}\n제공자: ${specInfo.provider_name}\n인스턴스 ID: ${instanceId}\n금액: ${orderRow.price_paid.toLocaleString()}원\n에러: ${String(lockError)}`, - }, - { - telegram: { - sendMessage: (chatId: number, text: string) => - sendMessage(env.BOT_TOKEN, chatId, text) - }, - adminId: env.SERVER_ADMIN_ID || env.DEPOSIT_ADMIN_ID || '', - env, - } - ); - } catch (notifyError) { - // 알림 실패는 로그만 기록 (메인 로직에 영향 없음) - logger.error('관리자 알림 전송 실패', notifyError as Error, { orderId }); - } - - // 2. 기존 에러 처리 로직 유지 - await env.DB.prepare( - "UPDATE server_orders SET status = 'failed', error_message = ?, provider_instance_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" - ).bind( - `결제 처리 실패 (인스턴스 생성 완료, 수동 정리 필요): ${String(lockError)}`, - String(instanceId), - orderId - ).run(); - - return { - success: false, - retryable: false, // 매우 중요: 인스턴스 이미 생성됨, 재시도 금지 - error: '결제 처리 중 오류가 발생했습니다. 관리자에게 문의하세요.', - }; - } - - // 10. Update order (status='active', IP address, provider_instance_id) - const orderUpdate = await env.DB.prepare( - `UPDATE server_orders - SET status = 'active', - provider_instance_id = ?, - ip_address = ?, - root_password = ?, - provisioned_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE id = ?` - ).bind( - String(instanceId), - ipAddress, - rootPassword, - orderId - ).run(); - - if (!orderUpdate.success) { - logger.error('Failed to update order after provisioning', undefined, { - orderId, - instanceId, - }); - // Don't fail here - instance is already created and paid - } - - // 11. Add to user_servers table (only existing columns) - const serverInsert = await env.DB.prepare( - `INSERT INTO user_servers (user_id, order_id, provider_id, label, verified) - VALUES (?, ?, ?, ?, 1)` - ).bind( - userId, - orderId, - specInfo.provider_id, - instanceLabel - ).run(); - - if (!serverInsert.success) { - logger.error('Failed to insert user_servers record', undefined, { - orderId, - instanceId, - }); - // Don't fail here - instance is already created and paid - } - - logger.info('Server provisioning completed successfully', { - orderId, - instanceId, - ipAddress, - provider: specInfo.provider_name, - plan: specInfo.instance_name, - telegramUserId, - }); - - // 12. Return success - return { - success: true, - order_id: orderId, - instance_id: String(instanceId), - ip_address: ipAddress, - root_password: rootPassword, - region: specInfo.region_name, - plan_label: specInfo.instance_name, - }; - - } catch (error) { - logger.error('Server provisioning failed', error as Error, { - orderId, - telegramUserId, - }); - - // Try to mark order as failed (best effort) - try { - await env.DB.prepare( - "UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" - ).bind( - String(error), - orderId - ).run(); - } catch (updateError) { - logger.error('Failed to update order status to failed', updateError as Error, { - orderId, - }); - } - - return { - success: false, - retryable: true, - error: `서버 프로비저닝 중 오류가 발생했습니다: ${String(error)}`, - }; - } -} - -/** - * Generate a secure random password (20 characters) - * - * Character set: uppercase, lowercase, digits, safe symbols - * Avoids ambiguous characters: I, l, 1, O, 0 - * - * @returns Random password string - */ -function generateSecurePassword(): string { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%'; - let password = ''; - const randomValues = new Uint8Array(20); - crypto.getRandomValues(randomValues); - for (let i = 0; i < 20; i++) { - password += chars[randomValues[i] % chars.length]; - } - return password; -} diff --git a/src/services/cloud-spec-service.ts b/src/services/cloud-spec-service.ts deleted file mode 100644 index be65238..0000000 --- a/src/services/cloud-spec-service.ts +++ /dev/null @@ -1,43 +0,0 @@ -export interface ServerSpec { - pricing_id: number; - vcpu: number; - memory_mb: number; - storage_gb: number; - transfer_tb: number; - network_speed_gbps: number | null; - monthly_price_krw: number; - region_name: string; -} - -/** - * 서버 사양 조회 (CLOUD_DB) - * - * @param db - CLOUD_DB 바인딩 - * @param plan - 인스턴스 ID (예: 'g6-nanode-1', 'vc2-1c-1gb') - * @param region - 리전 코드 (예: 'ap-northeast', 'nrt') - * @param provider - 제공자명 (소문자, 예: 'linode', 'vultr') - * @returns ServerSpec | null - */ -export async function getServerSpec( - db: D1Database, - plan: string, - region: string, - provider: string -): Promise { - return await db.prepare(` - SELECT - p.id as pricing_id, - it.vcpu, - it.memory_mb, - it.storage_gb, - it.transfer_tb, - it.network_speed_gbps, - p.monthly_price_krw, - r.region_name - FROM pricing p - JOIN instance_types it ON p.instance_type_id = it.id - JOIN regions r ON p.region_id = r.id AND r.provider_id = it.provider_id - JOIN providers pr ON it.provider_id = pr.id - WHERE it.instance_id = ? AND r.region_code = ? AND pr.name = ? - `).bind(plan, region, provider).first(); -} diff --git a/src/services/linode-api.ts b/src/services/linode-api.ts deleted file mode 100644 index 88a0b19..0000000 --- a/src/services/linode-api.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Linode API Client - * - * REST API 클라이언트 for Linode cloud provider - * - Instance management (create, get) - * - Region listing - * - Automatic retry with exponential backoff - */ - -import type { Env, LinodeInstance, LinodeCreateRequest } from '../types'; -import { createLogger } from '../utils/logger'; -import { retryWithBackoff } from '../utils/retry'; - -const logger = createLogger('linode-api'); - -/** - * Linode API Base URLs - */ -const DEFAULT_API_BASE = 'https://api.linode.com/v4'; - -/** - * Linode Region - */ -export interface LinodeRegion { - id: string; - label: string; - country: string; -} - -/** - * Linode API Error - */ -export class LinodeAPIError extends Error { - constructor( - message: string, - public readonly statusCode: number, - public readonly response?: any - ) { - super(message); - this.name = 'LinodeAPIError'; - } -} - -/** - * Create a Linode instance - * - * @param params - Instance creation parameters - * @param env - Environment variables (API key, base URL) - * @returns Created instance information - * @throws LinodeAPIError if API call fails - */ -export async function createInstance( - params: LinodeCreateRequest, - env: Env -): Promise { - const apiKey = env.LINODE_API_KEY; - if (!apiKey) { - throw new Error('LINODE_API_KEY is not configured'); - } - - const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE; - const url = `${apiBase}/linode/instances`; - - logger.info('Creating Linode instance', { - type: params.type, - region: params.region, - label: params.label, - }); - - return retryWithBackoff( - async () => { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - logger.error('Linode API create instance failed', undefined, { - status: response.status, - statusText: response.statusText, - error: errorBody, - }); - - throw new LinodeAPIError( - `Linode API error: ${response.status} ${response.statusText}`, - response.status, - errorBody - ); - } - - const data = await response.json() as LinodeInstance; - logger.info('Linode instance created successfully', { - id: data.id, - label: data.label, - ipv4: data.ipv4, - }); - - return data; - }, - { - maxRetries: 3, - initialDelayMs: 1000, - serviceName: 'linode-api', - } - ); -} - -/** - * Get a Linode instance by ID - * - * @param instanceId - Linode instance ID - * @param env - Environment variables (API key, base URL) - * @returns Instance information - * @throws LinodeAPIError if API call fails - */ -export async function getInstance( - instanceId: number, - env: Env -): Promise { - const apiKey = env.LINODE_API_KEY; - if (!apiKey) { - throw new Error('LINODE_API_KEY is not configured'); - } - - const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE; - const url = `${apiBase}/linode/instances/${instanceId}`; - - logger.info('Getting Linode instance', { instanceId }); - - return retryWithBackoff( - async () => { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - logger.error('Linode API get instance failed', undefined, { - status: response.status, - statusText: response.statusText, - instanceId, - error: errorBody, - }); - - throw new LinodeAPIError( - `Linode API error: ${response.status} ${response.statusText}`, - response.status, - errorBody - ); - } - - const data = await response.json() as LinodeInstance; - logger.info('Linode instance retrieved successfully', { - id: data.id, - label: data.label, - status: data.status, - }); - - return data; - }, - { - maxRetries: 3, - initialDelayMs: 1000, - serviceName: 'linode-api', - } - ); -} - -/** - * Get list of available Linode regions - * - * @param env - Environment variables (API key, base URL) - * @returns Array of available regions - * @throws LinodeAPIError if API call fails - */ -export async function getRegions(env: Env): Promise { - const apiKey = env.LINODE_API_KEY; - if (!apiKey) { - throw new Error('LINODE_API_KEY is not configured'); - } - - const apiBase = env.LINODE_API_BASE || DEFAULT_API_BASE; - const url = `${apiBase}/regions`; - - logger.info('Getting Linode regions'); - - return retryWithBackoff( - async () => { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - logger.error('Linode API get regions failed', undefined, { - status: response.status, - statusText: response.statusText, - error: errorBody, - }); - - throw new LinodeAPIError( - `Linode API error: ${response.status} ${response.statusText}`, - response.status, - errorBody - ); - } - - const responseData = await response.json() as { data: LinodeRegion[] }; - const regions = responseData.data; - - logger.info('Linode regions retrieved successfully', { - count: regions.length, - }); - - return regions; - }, - { - maxRetries: 3, - initialDelayMs: 1000, - serviceName: 'linode-api', - } - ); -} diff --git a/src/services/server-recommend-service.ts b/src/services/server-recommend-service.ts deleted file mode 100644 index bc02651..0000000 --- a/src/services/server-recommend-service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Env } from '../types'; - -export interface ServerRecommendation { - server: { - instance_id: string; - provider_name: string; - region_code: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - monthly_price: number; - }; - score: number; -} - -export interface RecommendOptions { - tech_stack?: string[]; - expected_users?: number; - use_case?: string; - lang?: string; -} - -/** - * 서버 추천 API 호출 헬퍼 함수 - * - * @param env - Cloudflare Workers 환경 변수 - * @param options - 추천 옵션 (기본값: nginx, 100명, general purpose) - * @returns 서버 추천 목록 - * @throws API 호출 실패 시 에러 - */ -export async function fetchServerRecommendations( - env: Env, - options?: RecommendOptions -): Promise { - const requestBody = { - tech_stack: options?.tech_stack || ['nginx'], - expected_users: options?.expected_users || 100, - use_case: options?.use_case || 'general purpose server', - lang: options?.lang || 'ko' - }; - - const response = env.SERVER_RECOMMEND - ? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - }) - : await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - }); - - if (!response.ok) { - throw new Error(`Recommendation API error: ${response.status}`); - } - - const data = await response.json() as { recommendations?: ServerRecommendation[] }; - - if (!data.recommendations || data.recommendations.length === 0) { - throw new Error('No recommendations available'); - } - - return data.recommendations; -} diff --git a/src/services/vultr-api.ts b/src/services/vultr-api.ts deleted file mode 100644 index e9b7375..0000000 --- a/src/services/vultr-api.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Vultr API Client - * - * REST API 클라이언트 for Vultr cloud provider - * - Instance management (create, get) - * - Region listing - * - Automatic retry with exponential backoff - */ - -import type { Env, VultrInstance, VultrCreateRequest } from '../types'; -import { createLogger } from '../utils/logger'; -import { retryWithBackoff } from '../utils/retry'; - -const logger = createLogger('vultr-api'); - -/** - * Vultr API Base URLs - */ -const DEFAULT_API_BASE = 'https://api.vultr.com/v2'; - -/** - * Vultr Region - */ -export interface VultrRegion { - id: string; - city: string; - country: string; - continent: string; - options: string[]; -} - -/** - * Vultr API Error - */ -export class VultrAPIError extends Error { - constructor( - message: string, - public readonly statusCode: number, - public readonly response?: any - ) { - super(message); - this.name = 'VultrAPIError'; - } -} - -/** - * Create a Vultr instance - * - * @param params - Instance creation parameters - * @param env - Environment variables (API key, base URL) - * @returns Created instance information - * @throws VultrAPIError if API call fails - */ -export async function createInstance( - params: VultrCreateRequest, - env: Env -): Promise { - const apiKey = env.VULTR_API_KEY; - if (!apiKey) { - throw new Error('VULTR_API_KEY is not configured'); - } - - const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE; - const url = `${apiBase}/instances`; - - logger.info('Creating Vultr instance', { - plan: params.plan, - region: params.region, - label: params.label, - }); - - return retryWithBackoff( - async () => { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - logger.error('Vultr API create instance failed', undefined, { - status: response.status, - statusText: response.statusText, - error: errorBody, - }); - - throw new VultrAPIError( - `Vultr API error: ${response.status} ${response.statusText}`, - response.status, - errorBody - ); - } - - const responseData = await response.json() as { instance: VultrInstance }; - const instance = responseData.instance; - - logger.info('Vultr instance created successfully', { - id: instance.id, - label: instance.label, - main_ip: instance.main_ip, - }); - - return instance; - }, - { - maxRetries: 3, - initialDelayMs: 1000, - serviceName: 'vultr-api', - } - ); -} - -/** - * Get a Vultr instance by ID - * - * @param instanceId - Vultr instance ID - * @param env - Environment variables (API key, base URL) - * @returns Instance information - * @throws VultrAPIError if API call fails - */ -export async function getInstance( - instanceId: string, - env: Env -): Promise { - const apiKey = env.VULTR_API_KEY; - if (!apiKey) { - throw new Error('VULTR_API_KEY is not configured'); - } - - const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE; - const url = `${apiBase}/instances/${instanceId}`; - - logger.info('Getting Vultr instance', { instanceId }); - - return retryWithBackoff( - async () => { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - logger.error('Vultr API get instance failed', undefined, { - status: response.status, - statusText: response.statusText, - instanceId, - error: errorBody, - }); - - throw new VultrAPIError( - `Vultr API error: ${response.status} ${response.statusText}`, - response.status, - errorBody - ); - } - - const responseData = await response.json() as { instance: VultrInstance }; - const instance = responseData.instance; - - logger.info('Vultr instance retrieved successfully', { - id: instance.id, - label: instance.label, - status: instance.status, - }); - - return instance; - }, - { - maxRetries: 3, - initialDelayMs: 1000, - serviceName: 'vultr-api', - } - ); -} - -/** - * Get list of available Vultr regions - * - * @param env - Environment variables (API key, base URL) - * @returns Array of available regions - * @throws VultrAPIError if API call fails - */ -export async function getRegions(env: Env): Promise { - const apiKey = env.VULTR_API_KEY; - if (!apiKey) { - throw new Error('VULTR_API_KEY is not configured'); - } - - const apiBase = env.VULTR_API_BASE || DEFAULT_API_BASE; - const url = `${apiBase}/regions`; - - logger.info('Getting Vultr regions'); - - return retryWithBackoff( - async () => { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - logger.error('Vultr API get regions failed', undefined, { - status: response.status, - statusText: response.statusText, - error: errorBody, - }); - - throw new VultrAPIError( - `Vultr API error: ${response.status} ${response.statusText}`, - response.status, - errorBody - ); - } - - const responseData = await response.json() as { regions: VultrRegion[] }; - const regions = responseData.regions; - - logger.info('Vultr regions retrieved successfully', { - count: regions.length, - }); - - return regions; - }, - { - maxRetries: 3, - initialDelayMs: 1000, - serviceName: 'vultr-api', - } - ); -} diff --git a/src/summary-service.ts b/src/summary-service.ts index c70497f..2ee4c1f 100644 --- a/src/summary-service.ts +++ b/src/summary-service.ts @@ -383,19 +383,11 @@ ${integratedProfile} - 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요. - 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요. - 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요. +- 서버, VPS, 클라우드, 호스팅, 인스턴스 관련 요청은 반드시 manage_server 도구를 사용하세요. 서버 추천 시 기술 스택과 예상 사용자 수를 확인하세요. 이전 대화에 서버 추천 결과가 있어도 항상 새로 도구를 호출하세요(가격/재고 변동). - 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요. - 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요. - 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요. -- 서버 추천, 서버 상담, VPS, 클라우드 서버 관련 요청은 반드시 manage_server 도구를 사용하세요. 직접 사양을 추천하지 마세요. -- 서버 추천 요청 시: - 1. 새로운 추천 요청은 이전 대화와 무관하게 처리 - 2. **반드시** 용도와 선호 위치를 먼저 질문: "어떤 용도로 사용하시나요? 서버 위치는 서울/도쿄/싱가포르 중 어디가 좋으세요?" - 3. 위치를 답하면 region 파라미터로 전달 (region="Seoul" 또는 region="Tokyo" 또는 region="Singapore") - 4. 용도와 위치를 알면 바로 추천. 예산/동접은 선택사항 - 5. 특정 기술(마인크래프트, Node.js 등)이 언급되면 lookup_docs로 요구사항 검색 - 6. manage_server 호출 시 현재 메시지에서 명시된 정보만 전달 -- manage_deposit, manage_domain, suggest_domains, manage_server 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요. -- 응답은 간결하고 도움이 되도록 한국어로 작성하세요.`; +- manage_deposit, manage_domain, manage_server, suggest_domains 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요.`; const recentContext = context.recentMessages.slice(-10).map((m) => ({ role: m.role === 'user' ? 'user' as const : 'assistant' as const, diff --git a/src/tools/index.ts b/src/tools/index.ts index 2fe2a20..2b5b9fd 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -56,18 +56,17 @@ const SuggestDomainsArgsSchema = z.object({ }); const ManageServerArgsSchema = z.object({ - action: z.enum(['recommend', 'list_specs', 'order', 'my_servers', 'server_info', 'cancel_order']), - purpose: z.string().max(500).optional(), - budget: z.number().positive().max(100000000).optional(), - spec_id: z.number().int().positive().optional(), - order_id: z.number().int().positive().optional(), - region: z.string().max(50).optional(), - label: z.string().max(100).optional(), - provider: z.enum(['linode', 'vultr']).optional(), - expected_users: z.number().int().positive().max(1000000).optional(), - daily_traffic: z.number().int().positive().max(100000000).optional(), - storage_needs_gb: z.number().positive().max(10000).optional(), - tech_stack: z.string().max(200).optional(), + action: z.enum(['recommend', 'order', 'start', 'stop', 'delete', 'list']), + tech_stack: z.array(z.string().min(1).max(100)).max(20).optional(), + expected_users: z.number().int().positive().optional(), + use_case: z.string().min(1).max(500).optional(), + traffic_pattern: z.enum(['steady', 'burst', 'high']).optional(), + region_preference: z.array(z.string().min(1).max(50)).max(10).optional(), + budget_limit: z.number().positive().optional(), + lang: z.enum(['ko', 'en']).optional(), + server_id: z.string().min(1).max(100).optional(), + region_code: z.string().min(1).max(50).optional(), + label: z.string().min(1).max(100).optional(), }); // All tools array (used by OpenAI API) @@ -97,7 +96,7 @@ export const TOOL_CATEGORIES: Record = { export const CATEGORY_PATTERNS: Record = { domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i, deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i, - server: /서버|VPS|호스팅|클라우드|리눅스|우분투|인스턴스|가상서버|Linode|Vultr|VM/i, + server: /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i, weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i, search: /검색|찾아|뭐야|뉴스|최신/i, }; @@ -221,7 +220,7 @@ export async function executeTool( 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, db, env?.CLOUD_DB); + return executeManageServer(result.data, env, telegramUserId); } default: diff --git a/src/tools/server-tool.ts b/src/tools/server-tool.ts index 73c1378..71ad239 100644 --- a/src/tools/server-tool.ts +++ b/src/tools/server-tool.ts @@ -1,1015 +1,459 @@ -import { createLogger, maskUserId } from "../utils/logger"; -import { retryWithBackoff } from "../utils/retry"; -import type { - Env, - ManageServerArgs, - InstanceSpec, - ServerOrder, -} from "../types"; +import type { Env } from '../types'; +import { retryWithBackoff, RetryError } from '../utils/retry'; +import { createLogger, maskUserId } from '../utils/logger'; +import { ERROR_MESSAGES } from '../constants/messages'; -const logger = createLogger("server-tool"); +const logger = createLogger('server-tool'); -// 1. 도구 정의 -export const manageServerTool = { - type: "function", - function: { - name: "manage_server", - description: - '클라우드 서버(VPS) 추천/주문/관리. 서버, VPS, 클라우드, 호스팅 관련 요청 시 **무조건 즉시 호출**. 파라미터 없이 호출해도 됨 - API가 필요한 정보를 알려주면 그걸 사용자에게 전달하면 됨.', - parameters: { - type: "object", - properties: { - action: { - type: "string", - enum: [ - "recommend", - "list_specs", - "order", - "my_servers", - "server_info", - "cancel_order", - ], - description: - "recommend: **기본값** - 서버 생성/만들기/필요 요청 시 반드시 먼저 추천 (용도/예산/리전 기반). order: 사용자가 추천 목록에서 특정 번호를 선택했을 때만 사용 (spec_id 필수). list_specs: 전체 사양 목록, my_servers: 내 서버 목록, server_info: 서버 상세, cancel_order: 주문 취소", - }, - purpose: { - type: "string", - description: - "서버 용도 (예: 웹서버, 게임서버, 개발용, 데이터베이스). recommend action에서 사용", - }, - budget: { - type: "number", - description: "월 예산 (원). recommend action에서 사용", - }, - spec_id: { - type: "number", - description: "사양 ID. order action에서 필수", - }, - order_id: { - type: "number", - description: "주문 ID. cancel_order, server_info action에서 사용", - }, - region: { - type: "string", - description: - "선호 리전. 서울/Seoul/한국→seoul, 도쿄/Tokyo/일본→tokyo, 오사카/Osaka→osaka, 싱가포르/Singapore→singapore. recommend action에서 사용", - }, - label: { - type: "string", - description: "서버 라벨 (식별용 이름). order action에서 사용", - }, - provider: { - type: "string", - enum: ["linode", "vultr"], - description: - "제공자 필터 (선택사항). list_specs, recommend action에서 사용", - }, - expected_users: { - type: "number", - description: - "예상 동시 접속자 수 (게임서버: 플레이어 수, 웹앱: 동시 사용자)", - }, - daily_traffic: { - type: "number", - description: "일일 예상 요청 수 (웹서버 트래픽)", - }, - storage_needs_gb: { - type: "number", - description: "필요한 스토리지 용량 (GB)", - }, - tech_stack: { - type: "string", - description: - "사용 기술 스택 (nodejs, python, java, php, wordpress, docker 등)", - }, - }, - required: ["action"], - }, - }, -}; - -// 2. 메인 실행 함수 -export async function executeManageServer( - args: ManageServerArgs, - env?: Env, - telegramUserId?: string, - db?: D1Database, - cloudDb?: D1Database, -): Promise { - const { - action, - purpose, - budget, - spec_id, - order_id, - region, - label, - provider, - expected_users, - daily_traffic, - storage_needs_gb, - tech_stack, - } = args; - logger.info("시작", { - action, - purpose, - budget, - spec_id, - order_id, - region, - label, - provider, - expected_users, - daily_traffic, - storage_needs_gb, - tech_stack, - userId: maskUserId(telegramUserId), - }); - - if (!telegramUserId || !db) { - return "🚫 서버 관리 기능을 사용할 수 없습니다."; - } - - if (!cloudDb) { - return "🚫 서버 관리 기능을 사용할 수 없습니다. (CLOUD_DB 미설정)"; - } - - // 사용자 조회 - const user = await db - .prepare("SELECT id FROM users WHERE telegram_id = ?") - .bind(telegramUserId) - .first<{ id: number }>(); - - if (!user) { - return "🚫 사용자 정보를 찾을 수 없습니다."; - } - - const userId = user.id; - - try { - switch (action) { - case "recommend": - return await recommendServer( - { - purpose, - budget, - provider, - expected_users, - daily_traffic, - storage_needs_gb, - tech_stack, - region, - }, - cloudDb, - env, - ); - - case "list_specs": - return await listSpecs(provider, cloudDb); - - case "order": - if (!spec_id) return "🚫 사양 ID를 지정해주세요."; - return await orderServer( - spec_id, - region, - label, - userId, - db, - cloudDb, - env, - ); - - case "my_servers": - return await getMyServers(userId, db, cloudDb); - - case "server_info": - if (!order_id) return "🚫 주문 ID를 지정해주세요."; - return await getServerInfo(order_id, userId, db, cloudDb); - - case "cancel_order": - if (!order_id) return "🚫 주문 ID를 지정해주세요."; - return await cancelOrder(order_id, userId, db); - - default: - return `🚫 알 수 없는 작업: ${action}`; - } - } catch (error) { - logger.error("오류", error as Error, { action }); - return `🚫 서버 관리 오류: ${String(error)}`; - } +// 언어 감지 (한글/일본어/중국어/영어) +function detectLanguage(text: string): 'ko' | 'ja' | 'zh' | 'en' { + if (/[가-힣]/.test(text)) return 'ko'; + if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'ja'; // 히라가나/가타카나 + if (/[\u4e00-\u9fff]/.test(text)) return 'zh'; // 한자 (일본어 감지 후) + return 'en'; } -// 3. 헬퍼 함수들 +// CDN 캐시 히트율 추정 (tech_stack + use_case 기반) +function estimateCdnCacheHitRate(techStack: string[], useCase: string): number | null { + const stackLower = techStack.map(s => s.toLowerCase()); + const useCaseLower = useCase.toLowerCase(); -// 리전 필터링 상수 -const REGION_FILTER = ` - ( - (prov.name = 'linode' AND (r.region_name LIKE '%Tokyo%' OR r.region_name LIKE '%Osaka%' OR r.region_name LIKE '%Singapore%')) - OR (prov.name = 'vultr' AND (r.region_name LIKE '%Seoul%' OR r.region_name LIKE '%Tokyo%' OR r.region_name LIKE '%Osaka%' OR r.region_name LIKE '%Singapore%')) - ) -`; + // CDN 키워드 감지 + const hasCdn = stackLower.some(s => + ['cloudflare', 'cdn', 'fastly', 'akamai', 'bunny', 'cf'].includes(s) + ); -// 서버 추천 (용도/예산 기반) -async function recommendServer( - args: { - purpose?: string; - budget?: number; - provider?: "linode" | "vultr"; - expected_users?: number; - daily_traffic?: number; - storage_needs_gb?: number; - tech_stack?: string; - region?: string; // 선호 리전 (Seoul, Tokyo, Singapore) - }, - _cloudDb: D1Database, // 외부 API 사용으로 현재 미사용 - env?: Env, -): Promise { - logger.info("recommendServer 시작", args); + if (!hasCdn) return null; // CDN 없으면 null 반환 - // 외부 추천 API 호출 - const apiResult = await callExternalRecommendAPI(args, env); + // use_case 기반 히트율 조정 + const isVideoStreaming = /video|streaming|vod|media|동영상|스트리밍|미디어/.test(useCaseLower); + const isStaticSite = /static|blog|portfolio|landing|정적|블로그/.test(useCaseLower); + const isApi = /api|backend|서버|백엔드/.test(useCaseLower); + const isEcommerce = /shop|store|commerce|쇼핑|이커머스/.test(useCaseLower); - // 사용자 질문이 필요한 경우 - if (apiResult.type === 'user_question') { - logger.info('사용자 추가 정보 필요', { question: apiResult.question }); - return apiResult.question; - } + // 콘텐츠 타입별 예상 캐시 히트율 + if (isVideoStreaming) return 0.92; // 비디오: 92% (대부분 캐시 가능) + if (isStaticSite) return 0.95; // 정적 사이트: 95% + if (isApi) return 0.30; // API: 30% (동적 콘텐츠 많음) + if (isEcommerce) return 0.70; // 이커머스: 70% (상품 이미지 캐시) - // 외부 API 실패 시 에러 메시지 - if (apiResult.type === 'error') { - logger.error("외부 API 실패", new Error("추천 API 호출 실패")); - return "🚫 서버 추천 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요."; - } - - // 추천 결과 사용 - const recommendations = apiResult.recommendations; - logger.info("외부 API 추천 성공", { count: recommendations.length }); - - // 상위 5개만 사용 - const topRecommendations = recommendations.slice(0, 5); - - // 리스트 형태로 출력 - const numEmojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣']; - let response = `🎯 ${args.purpose || "범용"} 서버 추천\n\n`; - - // 추천 서버 목록 (버튼 클릭 시 DB 조회용 정보 저장) - const specs = topRecommendations.map((rec, index) => ({ - num: index + 1, - plan: rec.server.instance_id, // 플랜 ID로 DB 조회 - region: rec.server.region_code, // 리전 코드로 DB 조회 - provider: rec.server.provider_name.toLowerCase(), - })); - - topRecommendations.forEach((rec, index) => { - const server = rec.server; - const ramGB = (server.memory_mb / 1024).toFixed(1); - const priceKrw = Math.round(server.monthly_price); - - response += `${numEmojis[index]} ${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD\n`; - response += ` 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 AI 점수: ${rec.score}/100\n\n`; - }); - - response += `👆 서버를 신청하려면 아래 버튼을 선택하세요`; - - // 키보드 마커 추가 - const keyboardData = JSON.stringify({ - type: "server_recommend", - specs - }); - - return `__KEYBOARD__${keyboardData}__END__${response}`; + return 0.85; // 기본값: 85% } -// 외부 API 요청 인터페이스 -interface ExternalRecommendRequest { - tech_stack: string[]; - expected_users?: number; // 선택 - 없으면 API가 missing_fields로 요청 - use_case: string; - traffic_pattern?: 'steady' | 'spiky' | 'growing'; - region_preference?: string[]; - budget_limit?: number; - provider_filter?: string[]; - lang?: string; // 응답 언어 (ko, en, ja, zh 등) +// Type guards +function isErrorResult(result: unknown): result is { error: string } { + return typeof result === 'object' && result !== null && 'error' in result; } -// 외부 API - 단일 추천 서버 정보 -interface ExternalRecommendation { - server: { - id: number; - provider_name: string; - instance_id: string; // 플랜 ID (예: vc2-1c-0.5gb-v6) - instance_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - monthly_price: number; - region_name: string; - region_code: string; // 리전 코드 (예: nrt, icn) - }; +// Cloud Orchestrator API 응답 타입 +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; +} + +interface BenchmarkItem { + name: string; + category: string; + score: number; + percentile: number; +} + +interface AvailableRegion { + region_name: string; + region_code: string; + monthly_price: number; +} + +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?: { + included_transfer_tb: number; + overage_cost_per_gb: number; + overage_cost_per_tb: number; + estimated_monthly_tb: number; + estimated_overage_tb: number; + estimated_overage_cost: number; + total_estimated_cost: number; + currency: string; + }; + 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[]; } -// 외부 API 응답 인터페이스 -interface ExternalRecommendResponse { - recommendations?: ExternalRecommendation[]; +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; - // 에러 응답 - error?: string; - missing_fields?: string[]; + cached?: boolean; } -// callExternalRecommendAPI 반환 타입 -type RecommendAPIResult = - | { type: 'success'; recommendations: ExternalRecommendation[] } - | { type: 'user_question'; question: string } - | { type: 'error' }; - -// 외부 서버 추천 API 호출 -async function callExternalRecommendAPI( - args: { - purpose?: string; - expected_users?: number; - daily_traffic?: number; - storage_needs_gb?: number; - tech_stack?: string; - region?: string; - budget?: number; - provider?: "linode" | "vultr"; +export const manageServerTool = { + type: 'function', + function: { + name: 'manage_server', + description: '클라우드 서버 관리 및 추천. "서버", "VPS", "클라우드", "호스팅" 등의 키워드가 포함되면 사용하세요.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['recommend', 'order', 'start', 'stop', 'delete', 'list'], + description: 'recommend: 서버 추천, order: 서버 신청 (준비 중), start: 서버 켜기 (준비 중), stop: 서버 끄기 (준비 중), delete: 서버 해지 (준비 중), list: 내 서버 목록 (준비 중)', + }, + tech_stack: { + type: 'array', + items: { type: 'string' }, + description: '사용할 기술 스택 (예: ["nodejs", "nginx", "postgresql"]). recommend action에서 필수', + }, + expected_users: { + type: 'number', + description: '예상 동시 접속자 수. recommend action에서 필수', + }, + use_case: { + type: 'string', + description: '사용 목적 설명 (예: "웹사이트 호스팅", "API 서버"). recommend action에서 필수', + }, + traffic_pattern: { + type: 'string', + enum: ['steady', 'spiky', 'growing'], + description: '트래픽 패턴. steady: 일정한 트래픽, spiky: 순간 급증, growing: 점진적 성장. recommend action에서 선택', + }, + region_preference: { + type: 'array', + items: { type: 'string' }, + description: '선호 리전 (예: ["tokyo", "seoul"]). recommend action에서 선택', + }, + budget_limit: { + type: 'number', + description: '월 예산 한도 (원). recommend action에서 선택', + }, + lang: { + type: 'string', + enum: ['ko', 'ja', 'zh', 'en'], + description: '응답 언어. 자동 감지됨', + }, + server_id: { + type: 'string', + description: '서버 ID. order/start/stop/delete action에서 필수', + }, + region_code: { + type: 'string', + description: '리전 코드 (예: "tokyo3"). order action에서 필수', + }, + label: { + type: 'string', + description: '서버 라벨 (예: "myapp-prod"). order action에서 필수', + }, + }, + required: ['action'], + }, }, - env?: Env -): Promise { - const apiUrl = env?.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend'; +}; - logger.info('외부 추천 API 호출 시작', { apiUrl, args }); +// Cloud Orchestrator API 호출 +async function callCloudOrchestratorApi( + endpoint: string, + method: string, + body?: Record, + env?: Env +): Promise { + logger.info('API 호출 시작', { + endpoint, + method, + useServiceBinding: !!env?.CLOUD_ORCHESTRATOR + }); try { - // 1. tech_stack 파싱 및 기본값 설정 - const techStackArray: string[] = []; - if (args.tech_stack) { - techStackArray.push( - ...args.tech_stack.split(',').map(s => s.trim().toLowerCase()).filter(Boolean) - ); - } - - // tech_stack이 비어있으면 purpose에서 추출 또는 기본값 설정 - if (techStackArray.length === 0) { - if (args.purpose) { - const purposeLower = args.purpose.toLowerCase(); - // purpose에서 기술 스택 추출 - if (purposeLower.includes('nodejs') || purposeLower.includes('node')) { - techStackArray.push('nodejs'); - } else if (purposeLower.includes('python')) { - techStackArray.push('python'); - } else if (purposeLower.includes('java')) { - techStackArray.push('java'); - } else if (purposeLower.includes('php')) { - techStackArray.push('php'); - } else if (purposeLower.includes('wordpress')) { - techStackArray.push('wordpress'); - } else if (purposeLower.includes('docker')) { - techStackArray.push('docker'); - } - } - - // 여전히 비어있으면 기본값 (API가 "general"을 인식 못함 → nginx 사용) - if (techStackArray.length === 0) { - techStackArray.push('nginx'); - } - } - - // 2. use_case 생성 (purpose 또는 tech_stack 기반) - 한글→영문 변환 - let useCase = args.purpose || args.tech_stack || '범용 서버'; - - // 한글 purpose를 영문으로 매핑 - const useCaseLower = useCase.toLowerCase(); - if (useCaseLower.includes('웹서버') || useCaseLower.includes('웹 서버')) { - useCase = 'web server'; - } else if (useCaseLower.includes('게임서버') || useCaseLower.includes('게임 서버')) { - useCase = 'game server'; - } else if (useCaseLower.includes('개발용') || useCaseLower.includes('개발 서버')) { - useCase = 'development server'; - } else if (useCaseLower.includes('api 서버') || useCaseLower.includes('api서버')) { - useCase = 'API server'; - } else if (useCaseLower.includes('데이터베이스') || useCaseLower.includes('db 서버')) { - useCase = 'database server'; - } else if (useCaseLower.includes('범용') || useCaseLower.includes('서버 추천') || useCaseLower === '범용 서버') { - useCase = 'general purpose server'; - } - // 기타 경우는 그대로 유지 (이미 영문이거나 API가 처리할 수 있는 경우) - - // 3. traffic_pattern 추론 - let trafficPattern: 'steady' | 'spiky' | 'growing' | undefined; - if (args.daily_traffic) { - if (args.daily_traffic >= 1000000) { - trafficPattern = 'spiky'; - } else if (args.daily_traffic >= 100000) { - trafficPattern = 'growing'; - } else { - trafficPattern = 'steady'; - } - } - - // 4. region_preference 매핑 - const regionPreference: string[] = []; - if (args.region) { - const regionLower = args.region.toLowerCase(); - if (regionLower.includes('seoul') || regionLower.includes('서울')) { - regionPreference.push('seoul'); - } else if (regionLower.includes('tokyo') || regionLower.includes('도쿄')) { - regionPreference.push('tokyo'); - } else if (regionLower.includes('singapore') || regionLower.includes('싱가포르')) { - regionPreference.push('singapore'); - } else if (regionLower.includes('osaka') || regionLower.includes('오사카')) { - regionPreference.push('osaka'); - } - } - - // 5. budget_limit (KRW → USD, 환율 1400) - let budgetLimitUsd: number | undefined; - if (args.budget) { - budgetLimitUsd = Math.round(args.budget / 1400); - } - - // 6. provider_filter - const providerFilter: string[] = []; - if (args.provider) { - providerFilter.push(args.provider); - } - - // 7. expected_users (기본값 없음 - API가 필요하면 missing_fields로 요청) - const expectedUsers = args.expected_users; - - // 8. 언어 감지 (purpose 기반) - const detectLanguage = (text?: string): string => { - if (!text) return 'ko'; // 기본값: 한국어 - if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja'; // 히라가나/카타카나 - if (/[\u4E00-\u9FFF]/.test(text) && !/[가-힣]/.test(text)) return 'zh'; // 한자만 (한글 없음) - if (/[가-힣]/.test(text)) return 'ko'; // 한글 - if (/^[a-zA-Z\s\d.,!?]+$/.test(text)) return 'en'; // 영문만 - return 'ko'; - }; - const lang = detectLanguage(args.purpose); - - // 9. 요청 본문 구성 (필수 필드만 포함, 선택 필드는 값 있을 때만) - const requestBody: ExternalRecommendRequest = { - tech_stack: techStackArray, - use_case: useCase, - lang, - }; - - // 선택 필드: 값이 있을 때만 포함 - if (expectedUsers !== undefined) requestBody.expected_users = expectedUsers; - if (trafficPattern) requestBody.traffic_pattern = trafficPattern; - if (regionPreference.length > 0) requestBody.region_preference = regionPreference; - if (budgetLimitUsd) requestBody.budget_limit = budgetLimitUsd; - if (providerFilter.length > 0) requestBody.provider_filter = providerFilter; - - logger.info('외부 API 요청 본문', requestBody); - - // 9. API 호출 - Service Binding 사용 (Cloudflare Error 1042 방지) - // Service Binding이 있으면 직접 Worker 호출, 없으면 URL로 호출 - const useServiceBinding = !!env?.SERVER_RECOMMEND; - logger.info('API 호출 방식', { useServiceBinding }); - - const abortController = new AbortController(); - const timeoutId = setTimeout(() => abortController.abort(), 15000); - - const fetchFn = useServiceBinding - ? () => env!.SERVER_RECOMMEND!.fetch('https://internal/api/recommend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }) - : () => fetch(apiUrl, { - method: 'POST', + const response = await retryWithBackoff( + () => { + const requestInit = { + method, headers: { 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0', }, - body: JSON.stringify(requestBody), - signal: abortController.signal, - }); + body: body ? JSON.stringify(body) : undefined, + }; - const response = await retryWithBackoff(fetchFn, { maxRetries: 1, initialDelayMs: 1000 }); - - clearTimeout(timeoutId); - - // 400 응답: missing_fields 체크 (필수 필드 누락) - if (response.status === 400) { - const errorBody = await response.text().catch(() => '{}'); - logger.warn('외부 API 400 응답', { body: errorBody.substring(0, 500) }); - - try { - const errorData = JSON.parse(errorBody) as { missing_fields?: string[] }; - if (errorData.missing_fields && errorData.missing_fields.length > 0) { - const questions: string[] = []; - const missingFields = errorData.missing_fields; - - if (missingFields.includes('use_case')) { - questions.push('• 어떤 용도로 사용하실 건가요? (예: 웹서버, 게임서버, API 서버, 개발용)'); - } - if (missingFields.includes('tech_stack')) { - questions.push('• 사용할 기술 스택은? (예: nodejs, nginx, mysql, python, wordpress)'); - } - if (missingFields.includes('expected_users')) { - questions.push('• 예상 동시 접속자 수는? (예: 10명, 100명, 1000명)'); - } - - const userMessage = `🤔 서버 추천을 위해 몇 가지 정보가 필요합니다:\n\n${questions.join('\n')}\n\n예시: "nodejs 웹서버, 동시 50명 예상"`; - return { type: 'user_question', question: userMessage }; + // Service Binding 우선, fallback: URL + if (env?.CLOUD_ORCHESTRATOR) { + logger.info('Service Binding 사용', { endpoint }); + return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${endpoint}`, requestInit); + } else { + const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev'; + const url = `${apiUrl}${endpoint}`; + logger.info('HTTP 요청 사용', { url }); + return fetch(url, requestInit); } - } catch { - // JSON 파싱 실패 - 일반 에러로 처리 - } - return { type: 'error' }; - } + }, + { maxRetries: 3, serviceName: 'cloud-orchestrator' } + ); - // 기타 에러 응답 if (!response.ok) { - const errorBody = await response.text().catch(() => 'Failed to read body'); - logger.warn('외부 API 응답 실패', { + const errorText = await response.text(); + logger.error('API 호출 실패', new Error(errorText), { + endpoint, status: response.status, - statusText: response.statusText, - body: errorBody.substring(0, 500) }); - return { type: 'error' }; + return { error: `서버 API 호출 실패: HTTP ${response.status}` }; } - const data = await response.json() as ExternalRecommendResponse; - - // 11. 정상 응답 파싱 - recommendations 배열 확인 - if (!data.recommendations || data.recommendations.length === 0) { - logger.warn('외부 API 추천 결과 없음', { data }); - return { type: 'error' }; - } - - logger.info('외부 API 추천 성공', { count: data.recommendations.length }); - return { type: 'success', recommendations: data.recommendations }; + const data = await response.json(); + logger.info('API 호출 성공', { endpoint }); + return data; } catch (error) { - logger.error('외부 API 호출 실패', error as Error, { apiUrl }); - return { type: 'error' }; + logger.error('API 호출 에러', error as Error, { endpoint }); + if (error instanceof RetryError) { + return { error: ERROR_MESSAGES.SERVER_SERVICE_UNAVAILABLE }; + } + return { error: `서버 API 호출 오류: ${String(error)}` }; } } - -// 전체 사양 목록 -async function listSpecs( - provider: "linode" | "vultr" | undefined, - cloudDb: D1Database, -): Promise { - logger.info("listSpecs 시작", { provider }); - - let query = ` - SELECT - p.id, - it.instance_name, - it.vcpu, - it.memory_mb, - it.storage_gb, - it.transfer_tb, - r.region_name, - p.monthly_price_krw, - prov.name as provider_name - FROM pricing p - JOIN instance_types it ON p.instance_type_id = it.id - JOIN regions r ON p.region_id = r.id - JOIN providers prov ON it.provider_id = prov.id - WHERE p.available = 1 - AND ${REGION_FILTER} - `; - - const bindings: string[] = []; - - if (provider) { - query += " AND prov.name = ?"; - bindings.push(provider); +// 서버 추천 결과 포맷팅 (필수 정보만) +// __DIRECT__ 마커로 AI 재해석 없이 바로 반환 +function formatRecommendations(data: RecommendResponse): string { + if (!data.recommendations || data.recommendations.length === 0) { + return '🔍 조건에 맞는 서버를 찾지 못했습니다.\n다른 조건으로 다시 시도해주세요.'; } - query += " ORDER BY p.monthly_price_krw ASC"; + const { recommendations } = data; - const { results } = await cloudDb - .prepare(query) - .bind(...bindings) - .all(); + // __DIRECT__ 마커 추가 - AI 재해석 방지 + let response = '__DIRECT__\n🖥️ 서버 추천 결과\n\n'; - if (!results || results.length === 0) { - return "🚫 등록된 서버 사양이 없습니다."; - } + recommendations.slice(0, 3).forEach((rec, index) => { + const server = rec.server; - let response = `📋 **서버 사양 목록** (${results.length}개)\n\n`; + // 가격 포맷팅 + const price = server.currency === 'KRW' + ? `₩${Math.round(server.monthly_price).toLocaleString()}` + : `$${server.monthly_price}`; - // 가격순으로 정렬된 목록 표시 - results.forEach((spec) => { - response += `#${spec.id}: ${spec.instance_name}\n`; - response += ` • ${spec.vcpu} vCPUs, ${(spec.memory_mb / 1024).toFixed(1)}GB RAM, ${spec.storage_gb}GB SSD\n`; - response += ` • 리전: ${spec.region_name}\n`; - response += ` • ${spec.monthly_price_krw.toLocaleString()}원/월\n\n`; - }); + // 대역폭 포맷팅 + const bandwidth = server.transfer_tb ? `${server.transfer_tb}TB` : '무제한'; - response += `💡 추천받으려면 "웹서버용 서버 추천" 형식으로 말씀해주세요.`; + response += `${index + 1}️⃣ ${server.instance_name} (${server.provider_name})\n`; + response += ` • 스펙: ${server.vcpu}vCPU / ${server.memory_gb}GB / ${server.storage_gb}GB SSD\n`; + response += ` • 리전: ${server.region_name} (${server.country_code})\n`; + response += ` • 가격: ${price}/월 (대역폭 ${bandwidth})\n`; - return response; -} - -// 서버 주문 (인라인 버튼 생성) -async function orderServer( - specId: number, - region: string | undefined, - label: string | undefined, - userId: number, - db: D1Database, - cloudDb: D1Database, - _env?: Env, -): Promise { - logger.info("orderServer 시작", { - specId, - region, - label, - userId: maskUserId(userId), - }); - - // 1. 사양 조회 (CLOUD_DB) - specId는 pricing.id - const spec = await cloudDb - .prepare( - ` - SELECT - p.id, - it.instance_name, - it.vcpu, - it.memory_mb, - it.storage_gb, - it.transfer_tb, - r.region_name, - r.region_code, - p.monthly_price_krw, - prov.name as provider_name - FROM pricing p - JOIN instance_types it ON p.instance_type_id = it.id - JOIN regions r ON p.region_id = r.id - JOIN providers prov ON it.provider_id = prov.id - WHERE p.id = ? AND p.available = 1 - LIMIT 1 - `, - ) - .bind(specId) - .first(); - - if (!spec) { - return `🚫 사양 #${specId}를 찾을 수 없습니다.`; - } - - // 2. 잔액 조회 (telegram-conversations DB) - let balance = 0; - const balanceRow = await db - .prepare("SELECT balance FROM user_deposits WHERE user_id = ?") - .bind(userId) - .first<{ balance: number }>(); - balance = balanceRow?.balance || 0; - - // 3. 기본값 설정 - region은 pricing에서 이미 결정됨 - const finalRegion = spec.region_code; - const finalLabel = label || `server-${Date.now()}`; - - logger.info("주문 준비", { - balance, - price: spec.monthly_price_krw, - region: finalRegion, - label: finalLabel, - }); - - // 4. 확인 페이지 생성 (인라인 버튼 포함) - if (balance >= spec.monthly_price_krw) { - // pending 주문 생성 (확인 버튼 클릭 시 실제 생성) - const insertResult = await db - .prepare( - ` - INSERT INTO server_orders (user_id, spec_id, region, price_paid) - VALUES (?, ?, ?, ?) - `, - ) - .bind(userId, specId, finalRegion, spec.monthly_price_krw) - .run(); - - if (!insertResult.success) { - logger.error("주문 생성 실패", new Error("DB insert failed"), { - userId: maskUserId(userId), - specId, - }); - return "🚫 주문 생성 중 오류가 발생했습니다."; + // 대역폭 정보 (항상 표시) + if (rec.bandwidth_info) { + const bw = rec.bandwidth_info; + if (bw.estimated_overage_cost > 0) { + // 초과 비용 있음 + const overageCost = bw.currency === 'KRW' + ? `₩${Math.round(bw.estimated_overage_cost).toLocaleString()}` + : `$${bw.estimated_overage_cost.toFixed(2)}`; + const totalCost = bw.currency === 'KRW' + ? `₩${Math.round(bw.total_estimated_cost).toLocaleString()}` + : `$${bw.total_estimated_cost.toFixed(2)}`; + response += ` • 예상 트래픽: ${bw.estimated_monthly_tb}TB → 초과 ${bw.estimated_overage_tb}TB (${overageCost})\n`; + response += ` • 총 예상 비용: ${totalCost}/월\n`; + } else { + // 포함 범위 내 + response += ` • 예상 트래픽: ${bw.estimated_monthly_tb}TB (포함 범위 내)\n`; + } } - const orderId = insertResult.meta?.last_row_id; - if (!orderId) { - logger.error("order_id 조회 실패", new Error("last_row_id is null")); - return "🚫 주문 ID를 가져올 수 없습니다."; + response += ` • 점수: ${rec.score}점`; + if (rec.estimated_capacity) { + response += ` / 최대 ${rec.estimated_capacity.max_concurrent_users.toLocaleString()}명\n`; + } else { + response += '\n'; } - - logger.info("pending 주문 생성 완료", { - orderId, - userId: maskUserId(userId), - specId, - }); - - // 버튼 데이터를 특수 마커로 포함 - const keyboardData = JSON.stringify({ - type: "server_order", - order_id: orderId, - spec_id: specId, - price: spec.monthly_price_krw, - region: finalRegion, - }); - - const ramGB = (spec.memory_mb / 1024).toFixed(1); - return `__KEYBOARD__${keyboardData}__END__ -📋 서버 주문 확인 - -• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD -• 트래픽: ${spec.transfer_tb}TB/월 -• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 -• 현재 잔액: ${balance.toLocaleString()}원 ✅ - -다음 단계에서 OS와 패키지를 선택합니다.`; - } else { - const shortage = spec.monthly_price_krw - balance; - - const ramGB = (spec.memory_mb / 1024).toFixed(1); - return `📋 서버 주문 확인 - -• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD -• 트래픽: ${spec.transfer_tb}TB/월 -• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월 -• 현재 잔액: ${balance.toLocaleString()}원 ⚠️ 부족 -• 부족 금액: ${shortage.toLocaleString()}원 - -💳 입금 계좌 -하나은행 427-910018-27104 (주식회사 아이언클래드) -입금 후 '홍길동 ${shortage}원 입금' 형식으로 알려주세요.`; - } -} - -// 내 서버 목록 -async function getMyServers( - userId: number, - db: D1Database, - cloudDb: D1Database, -): Promise { - logger.info("getMyServers 시작", { userId: maskUserId(userId) }); - - const { results } = await db - .prepare( - ` - SELECT - id as order_id, - label, - status, - ip_address, - region, - price_paid, - created_at, - provisioned_at, - spec_id - FROM server_orders - WHERE user_id = ? - ORDER BY created_at DESC - `, - ) - .bind(userId) - .all(); - - if (!results || results.length === 0) { - return '📋 주문한 서버가 없습니다.\n\n💡 "서버 추천" 또는 "서버 사양 목록"으로 시작하세요.'; - } - - let response = `📋 **내 서버 목록** (${results.length}개)\n\n`; - - // spec 정보를 배치로 조회 (N+1 문제 해결) - const specIds = [...new Set(results.map((r) => r.spec_id))]; - let specMap = new Map(); - - if (specIds.length > 0) { - const placeholders = specIds.map(() => "?").join(","); - const { results: specs } = await cloudDb - .prepare( - `SELECT id, instance_name FROM instance_types WHERE id IN (${placeholders})`, - ) - .bind(...specIds) - .all<{ id: number; instance_name: string }>(); - - if (specs && specs.length > 0) { - specMap = new Map(specs.map((s) => [s.id, s.instance_name])); - } - } - - // 주문 목록 표시 - for (const order of results) { - const specName = specMap.get(order.spec_id) || "알 수 없음"; - const statusIcon = - order.status === "active" - ? "✅" - : order.status === "pending" - ? "⏳" - : order.status === "provisioning" - ? "🔄" - : "❌"; - const statusLabel = - order.status === "active" - ? "운영중" - : order.status === "pending" - ? "대기" - : order.status === "provisioning" - ? "생성중" - : order.status === "terminated" - ? "종료" - : "오류"; - - response += `**#${order.order_id}** ${order.label} ${statusIcon}\n`; - response += ` • 사양: ${specName}\n`; - response += ` • 상태: ${statusLabel}\n`; - - if (order.ip_address) { - response += ` • IP: ${order.ip_address}\n`; - } - - response += ` • 리전: ${order.region}\n`; - response += ` • 가격: ${order.price_paid.toLocaleString()}원/월\n`; - response += ` • 생성일: ${new Date(order.created_at).toLocaleDateString("ko-KR")}\n\n`; - } - - response += `💡 상세 정보는 "서버 정보 #<주문ID>"로 조회하세요.`; - - return response; -} - -// 서버 상세 정보 -async function getServerInfo( - orderId: number, - userId: number, - db: D1Database, - cloudDb: D1Database, -): Promise { - logger.info("getServerInfo 시작", { orderId, userId: maskUserId(userId) }); - - const order = await db - .prepare( - ` - SELECT * - FROM server_orders - WHERE id = ? AND user_id = ? - `, - ) - .bind(orderId, userId) - .first(); - - if (!order) { - return `🚫 주문 #${orderId}를 찾을 수 없습니다. (권한 없음 또는 존재하지 않음)`; - } - - // spec 정보를 CLOUD_DB에서 조회 - const spec = await cloudDb - .prepare( - ` - SELECT - it.instance_name, - it.vcpu, - it.memory_mb, - it.storage_gb, - it.transfer_tb - FROM instance_types it - WHERE it.id = ? - `, - ) - .bind(order.spec_id) - .first<{ - instance_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - transfer_tb: number; - }>(); - - if (!spec) { - return `🚫 사양 정보를 찾을 수 없습니다.`; - } - - const statusIcon = - order.status === "active" - ? "✅" - : order.status === "pending" - ? "⏳" - : order.status === "provisioning" - ? "🔄" - : "❌"; - const statusLabel = - order.status === "active" - ? "운영중" - : order.status === "pending" - ? "대기" - : order.status === "provisioning" - ? "생성중" - : order.status === "terminated" - ? "종료" - : "오류"; - - let response = `🖥️ **서버 상세 정보** #${orderId} ${statusIcon}\n\n`; - response += `**기본 정보**\n`; - response += `• 라벨: ${order.label}\n`; - response += `• 상태: ${statusLabel}\n`; - response += `• 사양: ${spec.instance_name}\n`; - response += `• vCPUs: ${spec.vcpu}\n`; - response += `• RAM: ${(spec.memory_mb / 1024).toFixed(1)}GB\n`; - response += `• 디스크: ${spec.storage_gb}GB SSD\n`; - response += `• 트래픽: ${spec.transfer_tb}TB/월\n\n`; - - response += `**네트워크**\n`; - response += `• IPv4: ${order.ip_address || "할당 대기중"}\n`; - if (order.ipv6_address) { - response += `• IPv6: ${order.ipv6_address}\n`; - } - response += `• 리전: ${order.region}\n\n`; - - response += `**접속 정보**\n`; - response += `• OS: ${order.image}\n`; - if (order.status === "active") { - response += `• Root 비밀번호: 최초 생성 시에만 표시됩니다.\n`; - response += `• 분실 시 서버 콘솔에서 재설정하세요.\n`; - response += `• SSH: ssh root@${order.ip_address}\n`; - } else if (order.status === "pending") { - response += `• 비밀번호는 서버 생성 후 제공됩니다.\n`; - } - response += "\n"; - - response += `**결제 정보**\n`; - response += `• 가격: ${order.price_paid.toLocaleString()}원/월 (선불)\n`; - response += `• 생성일: ${new Date(order.created_at).toLocaleDateString("ko-KR")}\n`; - if (order.provisioned_at) { - response += `• 프로비저닝 완료: ${new Date(order.provisioned_at).toLocaleDateString("ko-KR")}\n`; - } - if (order.terminated_at) { - response += `• 종료일: ${new Date(order.terminated_at).toLocaleDateString("ko-KR")}\n`; - } - - if (order.error_message) { - response += `\n⚠️ **오류**\n${order.error_message}`; - } - - if (order.status === "pending") { - response += "\n\n💡 주문이 대기 중입니다. 관리자가 확인 후 생성됩니다."; - } - - return response; -} - -// 주문 취소 (pending 상태만 가능) -async function cancelOrder( - orderId: number, - userId: number, - db: D1Database, -): Promise { - logger.info("cancelOrder 시작", { orderId, userId: maskUserId(userId) }); - - // 주문 조회 - const order = await db - .prepare( - "SELECT status, price_paid FROM server_orders WHERE id = ? AND user_id = ?", - ) - .bind(orderId, userId) - .first<{ status: string; price_paid: number }>(); - - if (!order) { - return `🚫 주문 #${orderId}를 찾을 수 없습니다.`; - } - - if (order.status !== "pending") { - return `🚫 이미 처리 중이거나 완료된 주문은 취소할 수 없습니다. (현재 상태: ${order.status})`; - } - - // 주문 취소 - const updateResult = await db - .prepare( - 'UPDATE server_orders SET status = ?, terminated_at = datetime("now") WHERE id = ?', - ) - .bind("cancelled", orderId) - .run(); - - if (!updateResult.success) { - logger.error("주문 취소 실패", new Error("DB update failed"), { - orderId, - userId: maskUserId(userId), - }); - return "🚫 주문 취소 중 오류가 발생했습니다."; - } - - logger.info("주문 취소 완료", { - orderId, - userId: maskUserId(userId), - price: order.price_paid, + response += '\n'; }); - return `✅ 주문 #${orderId} 취소 완료\n\n• 차감되지 않은 금액: ${order.price_paid.toLocaleString()}원`; + return response.trim(); +} + +// 서버 작업 직접 실행 +async function executeServerAction( + action: string, + args: { + tech_stack?: string[]; + expected_users?: number; + use_case?: string; + traffic_pattern?: string; + region_preference?: string[]; + budget_limit?: number; + lang?: string; + server_id?: string; + region_code?: string; + label?: string; + }, + env?: Env, + telegramUserId?: string +): Promise { + logger.info('작업 시작', { + action, + userId: maskUserId(telegramUserId), + args: JSON.stringify(args).slice(0, 200), + }); + + switch (action) { + case 'recommend': { + const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args; + + // 필수 파라미터 검증 + if (!tech_stack || !expected_users || !use_case) { + return '🚫 서버 추천에는 tech_stack, expected_users, use_case가 필요합니다.'; + } + + // 언어 자동 감지 (use_case 기반) + const detectedLang = lang || detectLanguage(use_case); + + // CDN 캐시 히트율 추정 + const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case); + + // API 요청 body 구성 + const requestBody: Record = { + tech_stack, + expected_users, + use_case, + lang: detectedLang, + }; + + if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern; + if (region_preference) requestBody.region_preference = region_preference; + if (budget_limit) requestBody.budget_limit = budget_limit; + if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate; + + // API 호출 + const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env); + + if (isErrorResult(result)) { + return `🚫 ${result.error}`; + } + + // 결과 포맷팅 + return formatRecommendations(result as RecommendResponse); + } + + case 'order': { + const { server_id, region_code, label } = args; + + if (!server_id || !region_code || !label) { + return '🚫 서버 주문에는 server_id, region_code, label이 필요합니다.'; + } + + return '🚧 서버 주문 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.\n추후 업데이트를 통해 주문 기능이 추가될 예정입니다.'; + } + + case 'start': { + const { server_id } = args; + + if (!server_id) { + return '🚫 서버 시작에는 server_id가 필요합니다.'; + } + + return '🚧 서버 시작 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.'; + } + + case 'stop': { + const { server_id } = args; + + if (!server_id) { + return '🚫 서버 중지에는 server_id가 필요합니다.'; + } + + return '🚧 서버 중지 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.'; + } + + case 'delete': { + const { server_id } = args; + + if (!server_id) { + return '🚫 서버 해지에는 server_id가 필요합니다.'; + } + + return '🚧 서버 해지 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.'; + } + + case 'list': { + return '🚧 서버 목록 조회 기능은 준비 중입니다.\n\n현재 서버 추천 기능만 사용 가능합니다.'; + } + + default: + return `🚫 알 수 없는 작업: ${action}`; + } +} + +export async function executeManageServer( + args: { + action: string; + tech_stack?: string[]; + expected_users?: number; + use_case?: string; + traffic_pattern?: string; + region_preference?: string[]; + budget_limit?: number; + lang?: string; + server_id?: string; + region_code?: string; + label?: string; + }, + env?: Env, + telegramUserId?: string +): Promise { + const { action } = args; + logger.info('시작', { + action, + userId: maskUserId(telegramUserId), + }); + + try { + const result = await executeServerAction(action, args, env, telegramUserId); + logger.info('완료', { result: result?.slice(0, 100) }); + return result; + } catch (error) { + logger.error('오류', error as Error, { action }); + return `🚫 서버 관리 오류: ${String(error)}`; + } } diff --git a/src/types.ts b/src/types.ts index 90edaf7..df1cd4c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,5 @@ export interface Env { DB: D1Database; - CLOUD_DB: D1Database; AI: Ai; BOT_TOKEN: string; WEBHOOK_SECRET: string; @@ -22,26 +21,10 @@ export interface Env { BRAVE_API_BASE?: string; WTTR_IN_URL?: string; HOSTING_SITE_URL?: string; - LINODE_API_KEY?: string; - VULTR_API_KEY?: string; - LINODE_API_BASE?: string; - VULTR_API_BASE?: string; - SERVER_ADMIN_ID?: string; - SERVER_RECOMMEND_API_URL?: string; + CLOUD_ORCHESTRATOR_URL?: string; + CLOUD_ORCHESTRATOR?: Fetcher; // Service Binding RATE_LIMIT_KV: KVNamespace; SESSION_KV: KVNamespace; - // Service Binding: Worker-to-Worker 호출 - SERVER_RECOMMEND?: Fetcher; - // Queue Binding: 서버 프로비저닝 - SERVER_PROVISION_QUEUE: Queue; -} - -export interface ProvisionMessage { - order_id: number; - user_id: number; - telegram_user_id: string; - chat_id: number; // Telegram 알림용 - timestamp: number; } export interface IntentAnalysis { @@ -112,89 +95,6 @@ export interface ConversationContext { totalMessages: number; } -// Server Management - DB Entities -export interface CloudProvider { - id: number; - name: string; - display_name: string; - api_base_url: string; - enabled: number; -} - -export interface InstanceSpec { - id: number; - instance_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - transfer_tb: number; - monthly_price_krw: number; - region_name: string; - provider_name: string; // internal use only (don't show to user) -} - -export interface ServerOrder { - id: number; - user_id: number; - spec_id: number; - status: string; - provider_instance_id: string | null; - label: string; - region: string; - image: string; - ip_address: string | null; - ipv6_address: string | null; - root_password: string | null; - price_paid: number; - billing_type: string; - error_message: string | null; - provisioned_at: string | null; - terminated_at: string | null; - created_at: string; -} - -export interface UserServer { - id: number; - user_id: number; - order_id: number; - provider_id: number; - provider_instance_id: string; - label: string; - status: string; - ip_address: string; - verified: number; - created_at: string; - updated_at: string; -} - -// App Requirements Calculator Types -export interface AppRequirementTier { - minUsers: number; - maxUsers?: number; - vcpus: number; - memoryMb: number; - storageGb: number; - tierName?: string; -} - -export interface AppRequirement { - appType: 'game' | 'web' | 'database' | 'container' | 'dev'; - appName: string; - appNameKo?: string; - keywords: string[]; - baseVcpus: number; - baseMemoryMb: number; - baseStorageGb: number; - memoryPerUserMb: number; - vcpuPerUsers: number; - maxUsersPerInstance?: number; - scalingNote?: string; - tiers?: AppRequirementTier[]; - sourceUrl?: string; - confidenceLevel: 'low' | 'medium' | 'high' | 'official'; - fetchedAt?: string; -} - // Cloudflare Email Workers 타입 export interface EmailMessage { from: string; @@ -288,27 +188,6 @@ export interface SuggestDomainsArgs { keywords: string; } -export interface ManageServerArgs { - action: - | "recommend" - | "list_specs" - | "order" - | "my_servers" - | "server_info" - | "cancel_order"; - purpose?: string; - budget?: number; - spec_id?: number; - order_id?: number; - region?: string; - label?: string; - provider?: "linode" | "vultr"; - expected_users?: number; // 예상 동시 사용자 수 - daily_traffic?: number; // 일일 예상 트래픽 (요청 수) - storage_needs_gb?: number; // 필요한 스토리지 (GB) - tech_stack?: string; // 기술 스택 (nodejs, python, java, php 등) -} - export interface SearchWebArgs { query: string; } @@ -318,6 +197,26 @@ export interface LookupDocsArgs { query: string; } +export interface ManageServerArgs { + action: + | "recommend" + | "order" + | "start" + | "stop" + | "delete" + | "list"; + tech_stack?: string[]; + expected_users?: number; + use_case?: string; + traffic_pattern?: string; + region_preference?: string[]; + budget_limit?: number; + lang?: string; + server_id?: string; + region_code?: string; + label?: string; +} + // Deposit Agent 결과 타입 export interface DepositBalanceResult { balance: number; @@ -423,49 +322,6 @@ export interface BraveSearchResponse { }; } -// Linode API Types -export interface LinodeInstance { - id: number; - label: string; - status: string; - ipv4: string[]; - ipv6: string; - region: string; - type: string; - created: string; -} - -export interface LinodeCreateRequest { - type: string; - region: string; - image: string; - root_pass: string; - label?: string; - authorized_keys?: string[]; -} - -// Vultr API Types -export interface VultrInstance { - id: string; - label: string; - status: string; - main_ip: string; - v6_main_ip: string; - region: string; - plan: string; - date_created: string; - default_password: string; -} - -export interface VultrCreateRequest { - plan: string; - region: string; - os_id: number; - label?: string; - hostname?: string; - user_data?: string; // Base64 encoded cloud-init script for root password -} - // OpenAI API 응답 타입 export interface OpenAIMessage { role: string; @@ -506,25 +362,7 @@ export interface DomainRegisterKeyboardData { price: number; } -export interface ServerOrderKeyboardData { - type: "server_order"; - order_id: number; - spec_id: number; - price: number; - region: string; -} - -export interface ServerRecommendKeyboardData { - type: "server_recommend"; - specs: Array<{ - num: number; - plan: string; // 플랜 ID (예: vc2-1c-0.5gb-v6) - region: string; // 리전 코드 (예: nrt, icn) - provider: string; // 제공자 (linode, vultr) - }>; -} - -export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData | ServerRecommendKeyboardData; +export type KeyboardData = DomainRegisterKeyboardData; // Workers AI Types (from worker-configuration.d.ts) export type WorkersAIModel = diff --git a/src/utils/session.ts b/src/utils/session.ts deleted file mode 100644 index 3800075..0000000 --- a/src/utils/session.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * KV 기반 세션 관리 유틸리티 - * - 다단계 플로우 (서버 주문, 도메인 등록 등)의 임시 데이터 저장 - * - TTL 24시간 자동 만료 - */ - -export type SessionType = 'server_order' | 'domain_register'; - -export interface SessionData { - type: SessionType; - step: string; - data: T; - userId: number; - createdAt: number; - updatedAt: number; -} - -// 서버 주문 세션 데이터 -export interface ServerOrderSessionData { - // 추천 목록 (선택 전까지 임시 저장) - recommendations?: Array<{ - plan: string; - region: string; - provider: string; - }>; - - // 추천 정보 - purpose?: string; - budget?: number; - expectedUsers?: number; - - // 선택된 사양 - plan?: string; - provider?: string; - region?: string; - - // OS 선택 - image?: string; - - // 가격 (캐시) - priceKrw?: number; - - // 주문 ID (최종 확인 단계) - orderId?: number; -} - -// 서버 주문 단계 정의 (step 필드 타입 가이드) -export type ServerOrderStep = 'recommend' | 'spec_confirm' | 'os_select' | 'final_confirm'; - -const SESSION_TTL_SECONDS = 24 * 60 * 60; // 24시간 - -/** - * 세션 ID 생성 - * format: {type_prefix}_{userId}_{random} - */ -function generateSessionId(type: SessionType, userId: number): string { - const prefix = type === 'server_order' ? 'srv' : 'dom'; - const random = crypto.randomUUID().slice(0, 8); - return `${prefix}_${userId}_${random}`; -} - -/** - * 세션 생성 - */ -export async function createSession( - kv: KVNamespace, - userId: number, - type: SessionType, - initialData: T, - step: string = 'init' -): Promise { - const sessionId = generateSessionId(type, userId); - const now = Date.now(); - - const session: SessionData = { - type, - step, - data: initialData, - userId, - createdAt: now, - updatedAt: now, - }; - - await kv.put( - `session:${sessionId}`, - JSON.stringify(session), - { expirationTtl: SESSION_TTL_SECONDS } - ); - - // 사용자의 활성 세션 참조 저장 (같은 타입의 이전 세션 덮어쓰기) - await kv.put( - `user_session:${userId}:${type}`, - sessionId, - { expirationTtl: SESSION_TTL_SECONDS } - ); - - return sessionId; -} - -/** - * 세션 조회 - */ -export async function getSession( - kv: KVNamespace, - sessionId: string -): Promise | null> { - const raw = await kv.get(`session:${sessionId}`); - if (!raw) return null; - - try { - return JSON.parse(raw) as SessionData; - } catch { - return null; - } -} - -/** - * 세션 조회 + 권한 검증 - */ -export async function getSessionForUser( - kv: KVNamespace, - sessionId: string, - userId: number -): Promise | null> { - const session = await getSession(kv, sessionId); - - if (!session) return null; - if (session.userId !== userId) return null; - - return session; -} - -/** - * 사용자의 활성 세션 조회 - */ -export async function getUserActiveSession( - kv: KVNamespace, - userId: number, - type: SessionType -): Promise<{ sessionId: string; session: SessionData } | null> { - const sessionId = await kv.get(`user_session:${userId}:${type}`); - if (!sessionId) return null; - - const session = await getSession(kv, sessionId); - if (!session) { - // 참조는 있지만 세션이 만료됨 - 참조 정리 - await kv.delete(`user_session:${userId}:${type}`); - return null; - } - - return { sessionId, session }; -} - -/** - * 세션 업데이트 - */ -export async function updateSession( - kv: KVNamespace, - sessionId: string, - updates: Partial & { step?: string } -): Promise | null> { - const session = await getSession(kv, sessionId); - if (!session) return null; - - const { step, ...dataUpdates } = updates; - - const updated: SessionData = { - ...session, - step: step ?? session.step, - data: { ...session.data, ...dataUpdates } as T, - updatedAt: Date.now(), - }; - - await kv.put( - `session:${sessionId}`, - JSON.stringify(updated), - { expirationTtl: SESSION_TTL_SECONDS } - ); - - return updated; -} - -/** - * 세션 삭제 - */ -export async function deleteSession( - kv: KVNamespace, - sessionId: string -): Promise { - const session = await getSession(kv, sessionId); - - await kv.delete(`session:${sessionId}`); - - // 사용자 참조도 삭제 - if (session) { - await kv.delete(`user_session:${session.userId}:${session.type}`); - } -} - -/** - * 세션 만료 여부 확인 (UI용 메시지) - */ -export function isSessionExpired(session: SessionData | null): boolean { - if (!session) return true; - - const elapsed = Date.now() - session.createdAt; - return elapsed > SESSION_TTL_SECONDS * 1000; -} diff --git a/telegram-cli/README.md b/telegram-cli/README.md index 016ca2f..673ab5e 100644 --- a/telegram-cli/README.md +++ b/telegram-cli/README.md @@ -185,10 +185,6 @@ POST /api/chat → telegram-cli-web Worker # 예치금 "잔액 조회" "홍길동 5000원 입금" - -# 서버 -"서버 추천해줘" -"Linode 2GB 도쿄 서버 생성" ``` ### 2. API 테스트 (curl) @@ -199,15 +195,15 @@ curl -X POST https://your-worker.workers.dev/api/chat \ -H "Content-Type: application/json" \ -d '{"message": "안녕하세요"}' -# 서버 추천 -curl -X POST https://your-worker.workers.dev/api/chat \ - -H "Content-Type: application/json" \ - -d '{"message": "서버 추천해줘"}' - # 잔액 조회 curl -X POST https://your-worker.workers.dev/api/chat \ -H "Content-Type: application/json" \ -d '{"message": "/deposit"}' + +# 도메인 조회 +curl -X POST https://your-worker.workers.dev/api/chat \ + -H "Content-Type: application/json" \ + -d '{"message": "example.com 조회"}' ``` ### 3. Claude 사용 예시 diff --git a/wrangler.toml b/wrangler.toml index 45e32df..ce2a473 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,7 +6,7 @@ compatibility_date = "2024-01-01" binding = "AI" [vars] -ENVIRONMENT = "development" # 프로덕션 기본값 (.dev.vars에서 development로 오버라이드) +ENVIRONMENT = "production" SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수) MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우) N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택) @@ -20,23 +20,14 @@ CONTEXT7_API_BASE = "https://context7.com/api/v2" BRAVE_API_BASE = "https://api.search.brave.com/res/v1" WTTR_IN_URL = "https://wttr.in" HOSTING_SITE_URL = "https://hosting.anvil.it.com" +CLOUD_ORCHESTRATOR_URL = "https://cloud-orchestrator.kappa-d8e.workers.dev" -# VPS Provider API Endpoints -LINODE_API_BASE = "https://api.linode.com/v4" -VULTR_API_BASE = "https://api.vultr.com/v2" -DEFAULT_SERVER_REGION = "ap-northeast" # 오사카 (Linode: ap-northeast, Vultr: nrt) -SERVER_RECOMMEND_API_URL = "https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend" # 외부 AI 추천 API (선택) [[d1_databases]] binding = "DB" database_name = "telegram-conversations" database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" -[[d1_databases]] -binding = "CLOUD_DB" -database_name = "cloud-instances-db" -database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8" - [[kv_namespaces]] binding = "RATE_LIMIT_KV" id = "15bcdcbde94046fe936c89b2e7d85b64" @@ -47,16 +38,16 @@ binding = "SESSION_KV" id = "24ee962396cc4e9ab1fb47ceacf62c7d" preview_id = "302ad556567447cbac49c20bded4eb7e" -# Service Binding: Worker-to-Worker 호출용 (Cloudflare Error 1042 방지) -[[services]] -binding = "SERVER_RECOMMEND" -service = "cloud-orchestrator" - # Email Worker 설정 (SMS → 메일 수신) # Cloudflare Dashboard에서 Email Routing 설정 필요: # 1. Email > Email Routing > Routes # 2. deposit@your-domain.com → Worker: telegram-summary-bot +# Service Binding (Worker-to-Worker 통신) +[[services]] +binding = "CLOUD_ORCHESTRATOR" +service = "cloud-orchestrator" + # Cron Trigger: 매일 자정(KST) 실행 - 24시간 경과된 입금 대기 자동 취소 [triggers] crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00 @@ -71,29 +62,3 @@ 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 권장) -# - LINODE_API_KEY: Linode Personal Access Token -# - VULTR_API_KEY: Vultr API Key -# - SERVER_ADMIN_ID: 서버 관리 알림 수신자 Telegram ID - -# ============================================ -# Queue Configuration (Server Provisioning) -# ============================================ - -# Queue Producer 바인딩 -[[queues.producers]] -queue = "server-provision-queue" -binding = "SERVER_PROVISION_QUEUE" - -# Queue Consumer 바인딩 (같은 Worker에서 처리) -[[queues.consumers]] -queue = "server-provision-queue" -max_retries = 3 -max_batch_size = 1 -max_batch_timeout = 30 -max_concurrency = 3 -dead_letter_queue = "provision-dlq" - -# Dead Letter Queue Consumer -[[queues.consumers]] -queue = "provision-dlq" -max_retries = 0