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:
kappa
2026-01-26 12:26:21 +09:00
parent 5413605347
commit 87c92e1ed1
27 changed files with 695 additions and 4584 deletions

366
CLAUDE.md
View File

@@ -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
일시적인 문제로 서버를 생성할 수 없습니다.
✅ 결제 금액이 환불되었습니다.
관리자가 확인 후 연락드리겠습니다.
```

View File

@@ -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)를 참조하세요.
---
## 🧪 테스트
### 자동화된 단위 테스트

View File

@@ -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;

View File

@@ -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);

View File

@@ -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"

View File

@@ -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);

View File

@@ -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);

View File

@@ -26,6 +26,9 @@ export const ERROR_MESSAGES = {
// 날씨 관련
WEATHER_SERVICE_UNAVAILABLE: '날씨 정보를 가져올 수 없습니다',
// 서버 관련
SERVER_SERVICE_UNAVAILABLE: '🖥️ 서버 관리 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.',
} as const;
export type ErrorMessageKey = keyof typeof ERROR_MESSAGES;

View File

@@ -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;
}

View File

@@ -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}`);
},
};

View File

@@ -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,

View File

@@ -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();
}
}
}

View File

@@ -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 });
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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>();
}

View File

@@ -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',
}
);
}

View File

@@ -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;
}

View File

@@ -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',
}
);
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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 =

View File

@@ -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;
}

View File

@@ -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 사용 예시

View File

@@ -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