refactor: migrate server provisioning to Cloud Orchestrator service
- 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 <noreply@anthropic.com>
This commit is contained in:
366
CLAUDE.md
366
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
|
||||
|
||||
일시적인 문제로 서버를 생성할 수 없습니다.
|
||||
|
||||
✅ 결제 금액이 환불되었습니다.
|
||||
|
||||
관리자가 확인 후 연락드리겠습니다.
|
||||
```
|
||||
|
||||
60
README.md
60
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)를 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트
|
||||
|
||||
### 자동화된 단위 테스트
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
|
||||
39
schema.sql
39
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);
|
||||
|
||||
117
scripts/chat.ts
117
scripts/chat.ts
@@ -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<string> {
|
||||
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);
|
||||
@@ -26,6 +26,9 @@ export const ERROR_MESSAGES = {
|
||||
|
||||
// 날씨 관련
|
||||
WEATHER_SERVICE_UNAVAILABLE: '날씨 정보를 가져올 수 없습니다',
|
||||
|
||||
// 서버 관련
|
||||
SERVER_SERVICE_UNAVAILABLE: '🖥️ 서버 관리 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
} as const;
|
||||
|
||||
export type ErrorMessageKey = keyof typeof ERROR_MESSAGES;
|
||||
|
||||
@@ -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<string, { flag: string; name: string }> = {
|
||||
// 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;
|
||||
}
|
||||
19
src/index.ts
19
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<ProvisionMessage>, env: Env): Promise<void> {
|
||||
const QUEUE_HANDLERS: Record<string, (batch: MessageBatch<ProvisionMessage>, env: Env) => Promise<void>> = {
|
||||
'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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ProvisionMessage>,
|
||||
env: Env
|
||||
): Promise<void> {
|
||||
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 = `🎉 <b>서버 생성 완료!</b>
|
||||
|
||||
주문번호: #${result.order_id}
|
||||
|
||||
📋 <b>서버 정보</b>
|
||||
• 사양: <code>${result.plan_label || 'Unknown'}</code>
|
||||
• 리전: ${result.region || 'Unknown'}
|
||||
• IP 주소: <code>${result.ip_address || 'N/A'}</code>
|
||||
• 인스턴스 ID: <code>${result.instance_id || 'N/A'}</code>
|
||||
|
||||
🔐 <b>접속 정보</b>
|
||||
• Root 비밀번호: <code>${result.root_password || 'N/A'}</code>
|
||||
|
||||
📌 <b>SSH 접속 명령어</b>
|
||||
<code>ssh root@${result.ip_address || 'IP_ADDRESS'}</code>
|
||||
|
||||
⚠️ <b>보안 안내</b>
|
||||
• 비밀번호는 이 메시지에서만 확인 가능합니다.
|
||||
• 접속 후 즉시 변경해주세요.
|
||||
• 방화벽 설정을 권장합니다.`;
|
||||
|
||||
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 = `❌ <b>서버 생성 실패</b>
|
||||
|
||||
주문번호: #${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 = `❌ <b>서버 생성 처리 오류</b>
|
||||
|
||||
주문번호: #${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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ProvisionMessage>,
|
||||
env: Env
|
||||
): Promise<void> {
|
||||
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,
|
||||
`❌ <b>서버 생성 실패</b>
|
||||
|
||||
주문번호: #${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 });
|
||||
}
|
||||
@@ -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<ServerOrderSessionData>(
|
||||
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<ServerOrderSessionData>(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<ServerOrderSessionData>(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,
|
||||
`📦 <b>서버 사양 확인</b>
|
||||
|
||||
<b>컴퓨팅</b>
|
||||
• vCPU: ${spec.vcpu}개
|
||||
• RAM: ${ramGB}GB
|
||||
• 스토리지: ${spec.storage_gb}GB SSD
|
||||
|
||||
<b>네트워크</b>
|
||||
• 트래픽: ${spec.transfer_tb}TB/월
|
||||
• 대역폭: ${networkSpeed}
|
||||
|
||||
<b>요금</b>
|
||||
• 월 ${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<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
step: 'os_select'
|
||||
});
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`🖥️ <b>OS 선택</b>\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<ServerOrderSessionData>(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,
|
||||
`❌ <b>잔액 부족</b>
|
||||
|
||||
• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
• 부족 금액: ${shortage.toLocaleString()}원
|
||||
|
||||
💳 <b>입금 계좌</b>
|
||||
하나은행 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<ServerOrderSessionData>(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,
|
||||
`✅ <b>최종 확인</b>
|
||||
|
||||
• 사양: <b>${specStr}</b>
|
||||
• OS: ${getOSDisplayName(osImage)}
|
||||
• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
|
||||
💡 <b>요금 안내</b>
|
||||
• 월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다.
|
||||
• 예: 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,
|
||||
`📋 <b>서버 생성 주문 접수 완료!</b>
|
||||
|
||||
주문번호: #${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 = `🎯 <b>범용</b> 서버 추천\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]} <b>${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD</b>\n`;
|
||||
responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 AI 점수: ${rec.score}/100\n\n`;
|
||||
});
|
||||
responseText += `👆 <b>서버를 신청하려면 아래 버튼을 선택하세요</b>\n\n💡 다른 조건을 원하시면 "웹서버용 서버 추천" 형식으로 말씀해주세요.`;
|
||||
|
||||
// 새 세션 생성
|
||||
const newSessionId = await createSession<ServerOrderSessionData>(
|
||||
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<ServerOrderSessionData>(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,
|
||||
`📦 <b>서버 사양 확인</b>
|
||||
|
||||
<b>컴퓨팅</b>
|
||||
• vCPU: ${spec.vcpu}개
|
||||
• RAM: ${ramGB}GB
|
||||
• 스토리지: ${spec.storage_gb}GB SSD
|
||||
|
||||
<b>네트워크</b>
|
||||
• 트래픽: ${spec.transfer_tb}TB/월
|
||||
• 대역폭: ${networkSpeed}
|
||||
|
||||
<b>요금</b>
|
||||
• 월 ${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<ServerOrderSessionData>(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,
|
||||
`🖥️ <b>OS 선택</b>\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,
|
||||
`📋 <b>서버 생성 주문 접수 완료!</b>
|
||||
|
||||
주문번호: #${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);
|
||||
}
|
||||
|
||||
@@ -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<ServerOrderSessionData>(
|
||||
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<string, number> = {
|
||||
'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<ServerOrderSessionData>(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<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
|
||||
priceKrw: spec.monthly_price_krw
|
||||
});
|
||||
|
||||
const ramGB = (spec.memory_mb / 1024).toFixed(1);
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`📦 <b>${matchedIndex + 1}번 사양 선택</b>
|
||||
|
||||
<b>컴퓨팅</b>
|
||||
• vCPU: ${spec.vcpu}개
|
||||
• RAM: ${ramGB}GB
|
||||
• 스토리지: ${spec.storage_gb}GB SSD
|
||||
• 트래픽: ${spec.transfer_tb}TB/월
|
||||
|
||||
<b>요금</b>
|
||||
• 월 ${spec.monthly_price_krw.toLocaleString()}원
|
||||
|
||||
🖥️ <b>OS를 선택해주세요:</b>
|
||||
• "우분투" 또는 "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<ServerOrderSessionData>(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 = `🎯 <b>서버 추천</b>\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]} <b>${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD</b>\n`;
|
||||
responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`;
|
||||
});
|
||||
responseText += `💬 <b>원하시는 번호를 입력해주세요</b>\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<ServerOrderSessionData>(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 = `🎯 <b>새로운 서버 추천</b>\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]} <b>${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD</b>\n`;
|
||||
responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 점수: ${rec.score}/100\n\n`;
|
||||
});
|
||||
responseText += `💬 <b>원하시는 번호를 입력해주세요</b>\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,
|
||||
`❌ <b>잔액 부족</b>
|
||||
|
||||
• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}원
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
• 부족 금액: ${shortage.toLocaleString()}원
|
||||
|
||||
💳 <b>입금 계좌</b>
|
||||
하나은행 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<ServerOrderSessionData>(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,
|
||||
`✅ <b>최종 확인</b>
|
||||
|
||||
• 사양: ${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD
|
||||
• OS: ${getOSDisplayName(osImage)}
|
||||
• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월
|
||||
• 현재 잔액: ${balance.toLocaleString()}원
|
||||
|
||||
⚠️ <b>요금 안내</b>
|
||||
월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다.
|
||||
|
||||
🚀 서버를 생성하시려면 "<b>확인</b>" 또는 "<b>생성</b>"이라고 입력하세요.
|
||||
❌ 취소하시려면 "<b>취소</b>"라고 입력하세요.`
|
||||
);
|
||||
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<ServerOrderSessionData>(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,
|
||||
`🖥️ <b>OS를 다시 선택해주세요:</b>
|
||||
|
||||
현재 선택된 사양: ${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,
|
||||
`✅ <b>서버 생성 완료!</b>
|
||||
|
||||
• IP 주소: <code>${result.ip_address}</code>
|
||||
• Root 비밀번호: <code>${result.root_password}</code>
|
||||
|
||||
📌 <b>접속 방법</b>
|
||||
<code>ssh root@${result.ip_address}</code>
|
||||
|
||||
⚠️ <b>보안 권고</b>
|
||||
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
|
||||
2. SSH 키 인증 설정을 권장합니다.
|
||||
|
||||
🎉 서버가 성공적으로 생성되었습니다!`
|
||||
);
|
||||
} else {
|
||||
await sendMessage(env.BOT_TOKEN, chatId,
|
||||
`❌ <b>서버 생성 실패</b>
|
||||
|
||||
${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<ServerOrderSessionData>(
|
||||
env.SESSION_KV,
|
||||
userId,
|
||||
'server_order',
|
||||
{
|
||||
recommendations: specs.map(spec => ({
|
||||
plan: spec.plan,
|
||||
region: spec.region,
|
||||
provider: spec.provider
|
||||
}))
|
||||
},
|
||||
'recommend'
|
||||
);
|
||||
|
||||
// 대화형 안내 추가 (버튼 없이 메시지만)
|
||||
const guideText = `\n\n💬 <b>원하시는 번호를 입력해주세요</b>\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);
|
||||
|
||||
@@ -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<ProvisionResult> {
|
||||
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<OrderRow>();
|
||||
|
||||
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<SpecInfo>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<ServerSpec | null> {
|
||||
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<ServerSpec>();
|
||||
}
|
||||
@@ -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<LinodeInstance> {
|
||||
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<LinodeInstance> {
|
||||
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<LinodeRegion[]> {
|
||||
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',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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<ServerRecommendation[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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<VultrInstance> {
|
||||
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<VultrInstance> {
|
||||
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<VultrRegion[]> {
|
||||
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',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
export const CATEGORY_PATTERNS: Record<string, RegExp> = {
|
||||
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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
208
src/types.ts
208
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<ProvisionMessage>;
|
||||
}
|
||||
|
||||
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 =
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* KV 기반 세션 관리 유틸리티
|
||||
* - 다단계 플로우 (서버 주문, 도메인 등록 등)의 임시 데이터 저장
|
||||
* - TTL 24시간 자동 만료
|
||||
*/
|
||||
|
||||
export type SessionType = 'server_order' | 'domain_register';
|
||||
|
||||
export interface SessionData<T = unknown> {
|
||||
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<T>(
|
||||
kv: KVNamespace,
|
||||
userId: number,
|
||||
type: SessionType,
|
||||
initialData: T,
|
||||
step: string = 'init'
|
||||
): Promise<string> {
|
||||
const sessionId = generateSessionId(type, userId);
|
||||
const now = Date.now();
|
||||
|
||||
const session: SessionData<T> = {
|
||||
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<T>(
|
||||
kv: KVNamespace,
|
||||
sessionId: string
|
||||
): Promise<SessionData<T> | null> {
|
||||
const raw = await kv.get(`session:${sessionId}`);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as SessionData<T>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 조회 + 권한 검증
|
||||
*/
|
||||
export async function getSessionForUser<T>(
|
||||
kv: KVNamespace,
|
||||
sessionId: string,
|
||||
userId: number
|
||||
): Promise<SessionData<T> | null> {
|
||||
const session = await getSession<T>(kv, sessionId);
|
||||
|
||||
if (!session) return null;
|
||||
if (session.userId !== userId) return null;
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 활성 세션 조회
|
||||
*/
|
||||
export async function getUserActiveSession<T>(
|
||||
kv: KVNamespace,
|
||||
userId: number,
|
||||
type: SessionType
|
||||
): Promise<{ sessionId: string; session: SessionData<T> } | null> {
|
||||
const sessionId = await kv.get(`user_session:${userId}:${type}`);
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await getSession<T>(kv, sessionId);
|
||||
if (!session) {
|
||||
// 참조는 있지만 세션이 만료됨 - 참조 정리
|
||||
await kv.delete(`user_session:${userId}:${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sessionId, session };
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 업데이트
|
||||
*/
|
||||
export async function updateSession<T>(
|
||||
kv: KVNamespace,
|
||||
sessionId: string,
|
||||
updates: Partial<T> & { step?: string }
|
||||
): Promise<SessionData<T> | null> {
|
||||
const session = await getSession<T>(kv, sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const { step, ...dataUpdates } = updates;
|
||||
|
||||
const updated: SessionData<T> = {
|
||||
...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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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 사용 예시
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user