diff --git a/CLAUDE.md b/CLAUDE.md
index 2fbb1ab..5be60d7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -243,6 +243,9 @@ 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` | server_orders, server_specs 테이블 추가 | 2026-01-28 |
+| `004_add_terminated_at.sql` | server_orders.terminated_at 컬럼 추가 | 2026-01-28 |
+| `005_add_stopped_status.sql` | server_orders 테이블에 'stopped' 상태 추가 | 2026-01-29 |
**마이그레이션 작업 내용 (001):**
- `deposit_transactions.depositor_name_prefix` 컬럼 추가
@@ -251,7 +254,13 @@ wrangler d1 execute telegram-conversations --file=migrations/001_rollback.sql
- 기존 데이터 backfill (SUBSTR 함수로 자동 채우기)
- 성능: Full Table Scan → Index Scan
-**검증 명령:**
+**마이그레이션 작업 내용 (005):**
+- `server_orders` 테이블 CHECK constraint 수정 (SQLite 제약사항으로 테이블 재생성)
+- status 값에 'stopped' 추가 (pending, provisioning, active, stopped, failed, cancelled, terminated)
+- 모든 인덱스 재생성 (idx_server_orders_user, idx_server_orders_status, idx_server_orders_idempotency_unique)
+- 기존 데이터 보존 (임시 테이블 사용)
+
+**검증 명령 (001):**
```sql
-- 인덱스 사용 확인
EXPLAIN QUERY PLAN
@@ -262,6 +271,20 @@ WHERE status = 'pending' AND type = 'deposit'
-- 결과에 "USING INDEX idx_transactions_prefix_pending" 포함되어야 함
```
+**검증 명령 (005):**
+```sql
+-- CHECK constraint 확인 (stopped 상태 포함 여부)
+SELECT sql FROM sqlite_master WHERE name = 'server_orders';
+
+-- 인덱스 존재 확인
+SELECT name FROM sqlite_master
+WHERE type = 'index' AND tbl_name = 'server_orders';
+
+-- stopped 상태 테스트 (에러 없이 성공해야 함)
+-- INSERT INTO server_orders (user_id, spec_id, status, region, price_paid)
+-- VALUES (1, 1, 'stopped', 'Tokyo', 1000);
+```
+
---
## Code Style & Conventions
@@ -1353,6 +1376,44 @@ binding = "CLOUD_ORCHESTRATOR"
service = "cloud-orchestrator"
```
+### 서버 주문 상태 전이
+
+**상태 정의:**
+| 상태 | 설정 주체 | UI 표시 | 설명 |
+|------|----------|---------|------|
+| `pending` | telegram-bot | ❌ 표시 안 함 | Queue 대기 중 (내부용) |
+| `provisioning` | cloud-orchestrator | ✅ 🔄 생성 중... | Cloud Orchestrator 처리 중 |
+| `active` | cloud-orchestrator | ✅ 🟢 가동 중 | 서버 준비 완료 |
+| `stopped` | cloud-orchestrator | ✅ ⛔ 중지됨 | 서버 중지됨 |
+| `failed` | cloud-orchestrator | ❌ 표시 안 함 | 프로비저닝 실패 |
+| `terminated` | telegram-bot | ❌ 표시 안 함 | 서버 삭제 완료 |
+
+**상태 전이 흐름:**
+```
+사용자: "서버 신청"
+ ↓
+telegram-bot: POST /api/provision (status = 'pending')
+ ↓ Queue 등록
+cloud-orchestrator: Worker 처리
+ ├─ Queue 가져오기 → status = 'provisioning'
+ ├─ Incus 인스턴스 생성 (2-5분)
+ └─ 완료 → status = 'active'
+ ↓
+"내 서버 목록":
+ - pending: 표시 안 함 (내부용)
+ - provisioning: "🔄 생성 중..." 표시
+ - active: "🟢 가동 중" 표시
+```
+
+**상태 변경 권한:**
+- **telegram-bot**: `pending` 설정 (주문 생성), `terminated` 설정 (삭제 후)
+- **cloud-orchestrator**: `provisioning`, `active`, `stopped`, `failed` 설정
+
+**"내 서버 목록" 표시 규칙:**
+- ✅ **표시**: `provisioning`, `active` (+ provider_instance_id 존재)
+- ❌ **제외**: `pending`, `failed`, `terminated`, `deleted`
+- ❌ **제외**: `active`인데 `provider_instance_id`가 없는 경우 (프로비저닝 실패)
+
---
## Domain System
diff --git a/docs/CONSTANTS_MIGRATION.md b/docs/CONSTANTS_MIGRATION.md
new file mode 100644
index 0000000..585aa04
--- /dev/null
+++ b/docs/CONSTANTS_MIGRATION.md
@@ -0,0 +1,321 @@
+# Constants Migration Guide
+
+**Status**: Constants file created ✅ | Usage migration pending 🚧
+
+This document tracks the migration of magic strings to the centralized `/src/constants/index.ts` file.
+
+## Overview
+
+Magic strings have been extracted into constants for:
+- Better maintainability
+- Type safety
+- Consistency
+- Easier refactoring
+
+**Note**: This is a gradual migration. The constants file is ready, but actual usage changes should be done carefully to avoid breaking changes.
+
+## Usage Locations
+
+### SESSION_KEYS.DELETE_CONFIRM (`delete_confirm:`)
+
+**Files using this string:**
+- `/src/routes/api/chat.ts:213` - Delete confirmation session creation
+- `/src/routes/api/chat.ts:248` - Delete confirmation session retrieval
+- `/src/routes/handlers/message-handler.ts:60` - Delete confirmation check
+- `/src/tools/server-tool.ts:1005` - Server deletion confirmation
+
+**Migration example:**
+```typescript
+// Before
+const deleteSessionKey = `delete_confirm:${telegramUserId}`;
+
+// After
+import { SESSION_KEYS, sessionKey } from './constants';
+const deleteSessionKey = sessionKey(SESSION_KEYS.DELETE_CONFIRM, telegramUserId);
+```
+
+---
+
+### MESSAGE_MARKERS.DIRECT (`__DIRECT__`)
+
+**Files using this marker:**
+- `/src/services/conversation-service.ts:48-51` - Direct marker removal logic
+- `/src/openai-service.ts:327-352` - Early return check and processing
+- `/src/routes/api/chat.ts:127-130` - Web chat direct marker handling
+- `/src/routes/api/chat.ts:426-429` - Another web chat instance
+- `/src/tools/server-tool.ts:343-840` - Server tool responses (multiple locations)
+- `/src/tools/domain-tool.ts` - Domain tool responses
+- `/src/tools/troubleshoot-tool.ts:68` - Troubleshoot tool response
+- `/src/summary-service.ts:408` - System prompt instruction
+
+**Migration example:**
+```typescript
+// Before
+if (responseText.includes('__DIRECT__')) {
+ const directIndex = responseText.indexOf('__DIRECT__');
+ responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
+}
+
+// After
+import { MESSAGE_MARKERS } from './constants';
+if (responseText.includes(MESSAGE_MARKERS.DIRECT)) {
+ const directIndex = responseText.indexOf(MESSAGE_MARKERS.DIRECT);
+ responseText = responseText.slice(directIndex + MESSAGE_MARKERS.DIRECT.length).trim();
+}
+```
+
+---
+
+### MESSAGE_MARKERS.KEYBOARD (`__KEYBOARD__` and `__END__`)
+
+**Files using these markers:**
+- `/src/services/conversation-service.ts:69-80` - Keyboard parsing logic
+- `/src/openai-service.ts:327-328` - Early return check
+- `/src/tools/domain-tool.ts:799` - Keyboard data generation
+- `/src/routes/api/chat.ts:127-130` - Web chat (mentioned in grep)
+
+**Migration example:**
+```typescript
+// Before
+const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s);
+responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/s, '');
+
+// After
+import { MESSAGE_MARKERS } from './constants';
+const pattern = new RegExp(`${MESSAGE_MARKERS.KEYBOARD}(.+?)${MESSAGE_MARKERS.KEYBOARD_END}\\n?`, 's');
+const keyboardMatch = responseText.match(pattern);
+responseText = responseText.replace(pattern, '');
+```
+
+---
+
+### MESSAGE_MARKERS.PASSTHROUGH (`__PASSTHROUGH__`)
+
+**Files using this marker:**
+- `/src/server-agent.ts:660` - Session end, return to normal conversation
+- `/src/server-agent.ts:716` - Another session transition
+- `/src/openai-service.ts:233` - Function call result check
+- `/src/openai-service.ts:257` - Another function call result check
+- `/src/troubleshoot-agent.ts:421` - Agent transition
+
+**Migration example:**
+```typescript
+// Before
+if (result !== '__PASSTHROUGH__') {
+ return result;
+}
+
+// After
+import { MESSAGE_MARKERS } from './constants';
+if (result !== MESSAGE_MARKERS.PASSTHROUGH) {
+ return result;
+}
+```
+
+---
+
+### SESSION_KEYS.SERVER_SESSION (`server_session:`)
+
+**Files using this string:**
+- `/src/server-agent.ts` - Server consultation session management
+
+**Migration example:**
+```typescript
+// Before
+const sessionKey = `server_session:${userId}`;
+
+// After
+import { SESSION_KEYS, sessionKey } from './constants';
+const key = sessionKey(SESSION_KEYS.SERVER_SESSION, userId);
+```
+
+---
+
+### TRANSACTION_STATUS values
+
+**Files using these strings:**
+- `/src/routes/api/deposit.ts` - Deposit API endpoints
+- `/src/tools/deposit-tool.ts` - Deposit tool actions
+- `/src/deposit-agent.ts` - Deposit agent logic
+- `/src/services/deposit-matcher.ts` - Auto-matching service
+- `/src/utils/reconciliation.ts` - Reconciliation job
+- `/tests/deposit-agent.test.ts` - Test cases
+
+**Common patterns:**
+- `status = 'pending'`
+- `status = 'confirmed'`
+- `status = 'cancelled'`
+- `status = 'rejected'`
+
+**Migration example:**
+```typescript
+// Before
+const result = await env.DB.prepare(
+ 'SELECT * FROM deposit_transactions WHERE status = ?'
+).bind('pending').all();
+
+// After
+import { TRANSACTION_STATUS } from './constants';
+const result = await env.DB.prepare(
+ 'SELECT * FROM deposit_transactions WHERE status = ?'
+).bind(TRANSACTION_STATUS.PENDING).all();
+```
+
+---
+
+### SERVER_ORDER_STATUS values
+
+**Files using these strings:**
+- `/src/tools/server-tool.ts` - Server management tool
+- `/src/server-provision.ts` - Server provisioning logic
+
+**Common patterns:**
+- `status = 'pending'`
+- `status = 'provisioning'`
+- `status = 'active'`
+- `status = 'failed'`
+- `status = 'terminated'`
+
+**Migration example:**
+```typescript
+// Before
+if (order.status === 'active') {
+ // ...
+}
+
+// After
+import { SERVER_ORDER_STATUS } from './constants';
+if (order.status === SERVER_ORDER_STATUS.ACTIVE) {
+ // ...
+}
+```
+
+---
+
+### CALLBACK_PREFIXES values
+
+**Files using these strings:**
+- `/src/routes/handlers/callback-query-handler.ts` - Callback query routing
+- `/src/tools/domain-tool.ts` - Domain registration buttons
+- `/src/tools/server-tool.ts` - Server order buttons
+
+**Common patterns:**
+- `confirm_domain_register:example.com:15000`
+- `cancel_domain_register:example.com`
+- `confirm_server_order:order_123`
+
+**Migration example:**
+```typescript
+// Before
+if (data.startsWith('confirm_domain_register:')) {
+ // ...
+}
+
+// After
+import { CALLBACK_PREFIXES } from './constants';
+if (data.startsWith(`${CALLBACK_PREFIXES.DOMAIN_REGISTER_CONFIRM}:`)) {
+ // ...
+}
+```
+
+---
+
+## Migration Strategy
+
+### Phase 1: Foundation (✅ Complete)
+- [x] Create `/src/constants/index.ts`
+- [x] Define all constants with proper types
+- [x] Add helper functions (`sessionKey`, `parseSessionKey`)
+- [x] TypeScript compilation check passes
+
+### Phase 2: High-Priority Migration (🚧 Pending)
+**Target**: Critical paths with high maintenance burden
+
+1. **Session keys** (low risk, high benefit)
+ - `SESSION_KEYS.DELETE_CONFIRM`
+ - `SESSION_KEYS.SERVER_ORDER_CONFIRM`
+ - `SESSION_KEYS.SERVER_SESSION`
+
+2. **Transaction status checks** (medium risk, high benefit)
+ - Database queries with status filters
+ - Status transitions in business logic
+
+### Phase 3: Message Markers (🚧 Pending)
+**Target**: AI response processing
+
+1. **Direct marker processing**
+ - Standardize removal logic
+ - Create shared utility function
+
+2. **Keyboard marker processing**
+ - Centralize regex patterns
+ - Improve error handling
+
+3. **Passthrough checks**
+ - Simple find-and-replace
+
+### Phase 4: Complete Migration (📅 Future)
+**Target**: All remaining magic strings
+
+- Action constants (SERVER_ACTION, DOMAIN_ACTION, DEPOSIT_ACTION)
+- Callback prefixes
+- Any remaining hardcoded strings
+
+---
+
+## Testing Checklist
+
+Before merging any migration:
+
+- [ ] TypeScript compilation passes (`npx tsc --noEmit`)
+- [ ] All tests pass (`npm test`)
+- [ ] Manual testing of affected features
+- [ ] No breaking changes to API contracts
+- [ ] Documentation updated if needed
+
+---
+
+## Benefits of Migration
+
+1. **Type Safety**: TypeScript can catch typos at compile time
+2. **Consistency**: All code uses the same string values
+3. **Refactoring**: Change once, update everywhere
+4. **Documentation**: Constants serve as inline documentation
+5. **IDE Support**: Autocomplete and go-to-definition
+
+---
+
+## Example: Complete Before/After
+
+### Before
+```typescript
+// Multiple files, inconsistent usage
+const key1 = `delete_confirm:${userId}`;
+const key2 = 'delete_confirm:' + userId;
+if (status === 'pending') { }
+if (result === '__PASSTHROUGH__') { }
+```
+
+### After
+```typescript
+import { SESSION_KEYS, TRANSACTION_STATUS, MESSAGE_MARKERS, sessionKey } from './constants';
+
+const key1 = sessionKey(SESSION_KEYS.DELETE_CONFIRM, userId);
+const key2 = sessionKey(SESSION_KEYS.DELETE_CONFIRM, userId); // Consistent!
+if (status === TRANSACTION_STATUS.PENDING) { }
+if (result === MESSAGE_MARKERS.PASSTHROUGH) { }
+```
+
+---
+
+## Notes
+
+- **Gradual migration**: Don't rush. Each change should be tested thoroughly.
+- **Backward compatibility**: Ensure old code still works during transition.
+- **Documentation**: Update CLAUDE.md and README.md when major migrations complete.
+- **Communication**: Notify team when changing widely-used constants.
+
+---
+
+**Last Updated**: 2026-01-29
+**Status**: Foundation complete, awaiting gradual migration
diff --git a/migrations/005_add_stopped_status.sql b/migrations/005_add_stopped_status.sql
new file mode 100644
index 0000000..8788894
--- /dev/null
+++ b/migrations/005_add_stopped_status.sql
@@ -0,0 +1,62 @@
+-- Migration: Add 'stopped' status to server_orders table
+-- Date: 2026-01-29
+-- Description: SQLite does not support ALTER TABLE to modify CHECK constraints,
+-- so we need to recreate the table with the new constraint.
+
+-- Step 1: Create temporary table with new CHECK constraint
+CREATE TABLE server_orders_new (
+ 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', 'stopped', '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,
+ label TEXT,
+ image TEXT,
+ billing_type TEXT DEFAULT 'monthly',
+ expires_at DATETIME,
+ telegram_user_id TEXT,
+ provider TEXT DEFAULT 'anvil',
+ idempotency_key TEXT,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+-- Step 2: Copy existing data
+INSERT INTO server_orders_new (
+ id, user_id, spec_id, status, region, provider_instance_id, ip_address,
+ root_password, price_paid, error_message, provisioned_at, terminated_at,
+ created_at, updated_at, label, image, billing_type, expires_at,
+ telegram_user_id, provider, idempotency_key
+)
+SELECT
+ id, user_id, spec_id, status, region, provider_instance_id, ip_address,
+ root_password, price_paid, error_message, provisioned_at, terminated_at,
+ created_at, updated_at, label, image, billing_type, expires_at,
+ telegram_user_id, provider, idempotency_key
+FROM server_orders;
+
+-- Step 3: Drop old table
+DROP TABLE server_orders;
+
+-- Step 4: Rename new table
+ALTER TABLE server_orders_new RENAME TO server_orders;
+
+-- Step 5: Recreate indexes
+CREATE INDEX idx_server_orders_user ON server_orders(user_id);
+
+CREATE INDEX idx_server_orders_status ON server_orders(status, created_at DESC);
+
+CREATE UNIQUE INDEX idx_server_orders_idempotency_unique
+ ON server_orders(idempotency_key)
+ WHERE idempotency_key IS NOT NULL;
+
+-- Verification query (comment out for actual migration)
+-- SELECT COUNT(*) as total_orders, COUNT(DISTINCT status) as status_count FROM server_orders;
diff --git a/migrations/005_rollback_stopped_status.sql b/migrations/005_rollback_stopped_status.sql
new file mode 100644
index 0000000..3214dc5
--- /dev/null
+++ b/migrations/005_rollback_stopped_status.sql
@@ -0,0 +1,67 @@
+-- Rollback Migration: Remove 'stopped' status from server_orders table
+-- Date: 2026-01-29
+-- Description: Revert to original CHECK constraint without 'stopped' status
+-- WARNING: This will fail if there are any rows with status='stopped'
+
+-- Step 1: Verify no 'stopped' status exists (will fail if found)
+-- If this SELECT returns rows, manual intervention required
+SELECT CASE
+ WHEN COUNT(*) > 0 THEN RAISE(ABORT, 'Cannot rollback: server_orders contains stopped status records')
+ ELSE 0
+END
+FROM server_orders WHERE status = 'stopped';
+
+-- Step 2: Create temporary table with original CHECK constraint
+CREATE TABLE server_orders_rollback (
+ 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,
+ label TEXT,
+ image TEXT,
+ billing_type TEXT DEFAULT 'monthly',
+ expires_at DATETIME,
+ telegram_user_id TEXT,
+ provider TEXT DEFAULT 'anvil',
+ idempotency_key TEXT,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+-- Step 3: Copy existing data
+INSERT INTO server_orders_rollback (
+ id, user_id, spec_id, status, region, provider_instance_id, ip_address,
+ root_password, price_paid, error_message, provisioned_at, terminated_at,
+ created_at, updated_at, label, image, billing_type, expires_at,
+ telegram_user_id, provider, idempotency_key
+)
+SELECT
+ id, user_id, spec_id, status, region, provider_instance_id, ip_address,
+ root_password, price_paid, error_message, provisioned_at, terminated_at,
+ created_at, updated_at, label, image, billing_type, expires_at,
+ telegram_user_id, provider, idempotency_key
+FROM server_orders;
+
+-- Step 4: Drop current table
+DROP TABLE server_orders;
+
+-- Step 5: Rename rollback table
+ALTER TABLE server_orders_rollback RENAME TO server_orders;
+
+-- Step 6: Recreate indexes
+CREATE INDEX idx_server_orders_user ON server_orders(user_id);
+
+CREATE INDEX idx_server_orders_status ON server_orders(status, created_at DESC);
+
+CREATE UNIQUE INDEX idx_server_orders_idempotency_unique
+ ON server_orders(idempotency_key)
+ WHERE idempotency_key IS NOT NULL;
diff --git a/src/commands.ts b/src/commands.ts
index b4e3884..e0de445 100644
--- a/src/commands.ts
+++ b/src/commands.ts
@@ -15,40 +15,66 @@ export async function handleCommand(
switch (command) {
case '/start':
- return `👋 안녕하세요! AI 어시스턴트입니다.
+ return `👋 AnvilHosting 고객센터입니다!
-대화를 나눌수록 당신을 더 잘 이해합니다 💡
+제공 서비스:
+• 🌐 도메인 등록/관리
+• 🖥️ 클라우드 서버 (서울/도쿄/오사카/싱가폴)
+• 🛡️ DDoS 방어
+• 🔐 PhantomX VPN (Xray 기반 차세대 보안)
명령어:
-/profile - 내 프로필 보기
/help - 도움말
+/deposit - 예치금 잔액
+/domain - 내 도메인 목록
+/server - 내 서버 목록
+/security - DDoS 방어 현황
+/phantomx - PhantomX VPN
-💡 중요한 정보는 "기억해줘"로 저장하세요!`;
+무엇을 도와드릴까요?`;
case '/help':
return `📖 도움말
-/profile - 내 프로필 보기
+명령어:
+/deposit - 예치금 잔액
+/domain - 내 도메인 목록
+/server - 내 서버 목록
+/security - DDoS 방어 서비스
+/phantomx - PhantomX VPN 서비스
-기억 기능:
-• "OOO 기억해줘" - 정보 저장
-• "내 기억 보여줘" - 저장 목록
-• "OOO 잊어줘" - 삭제
+자연어로 요청:
+• "도메인 등록" - 도메인 검색/등록
+• "서버 추천" - 맞춤 서버 추천
-대화할수록 당신을 더 잘 이해합니다 💡`;
+궁금한 점은 편하게 물어보세요!`;
+
+ case '/deposit': {
+ const deposit = await env.DB
+ .prepare('SELECT balance FROM user_deposits WHERE user_id = ?')
+ .bind(userId)
+ .first<{ balance: number }>();
+
+ const balance = deposit?.balance ?? 0;
+
+ return `💰 예치금 잔액
+
+현재 잔액: ${balance.toLocaleString()}원
+
+입금 계좌:
+하나은행 427-910018-27104
+예금주: (주)아이언클래드
+
+입금 후 "홍길동 10000원 입금" 형식으로 알려주세요.`;
+ }
case '/context': {
const ctx = await getConversationContext(env.DB, userId, chatId);
- const remaining = config.threshold - ctx.recentMessages.length;
return `📊 현재 컨텍스트
-분석된 메시지: ${ctx.previousSummary?.message_count ?? 0}개
-버퍼 메시지: ${ctx.recentMessages.length}개
-프로필 버전: ${ctx.previousSummary?.generation ?? 0}
총 메시지: ${ctx.totalMessages}개
-
-💡 ${remaining > 0 ? `${remaining}개 메시지 후 프로필 업데이트` : '업데이트 대기 중'}`;
+버퍼: ${ctx.recentMessages.length}개`;
}
case '/profile':
@@ -83,6 +109,162 @@ ${summary.summary}
버퍼 대기: ${ctx.recentMessages.length}개`;
}
+ case '/domain': {
+ const domains = await env.DB
+ .prepare(`
+ SELECT domain, created_at
+ FROM user_domains
+ WHERE user_id = ? AND verified = 1
+ ORDER BY created_at DESC
+ `)
+ .bind(userId)
+ .all<{ domain: string; created_at: string }>();
+
+ if (!domains.results || domains.results.length === 0) {
+ return `🌐 내 도메인
+
+등록된 도메인이 없습니다.
+
+"도메인 등록" 또는 "example.com 등록"으로 시작하세요!`;
+ }
+
+ const domainList = domains.results.map((d, i) => {
+ const date = new Date(d.created_at).toLocaleDateString('ko-KR');
+ return `${i + 1}. ${d.domain} (${date})`;
+ }).join('\n');
+
+ return `🌐 내 도메인 (${domains.results.length}개)
+
+${domainList}
+
+도메인 관리: "도메인명 네임서버 변경"`;
+ }
+
+ case '/server': {
+ // Cloud Orchestrator API를 통해 스펙 정보 포함된 서버 목록 조회
+ const telegramUserId = chatId; // chatId가 실제로는 telegram_user_id
+
+ interface ServerWithSpecs {
+ id: number;
+ label: string | null;
+ status: string;
+ region: string;
+ vcpu?: number;
+ memory_gb?: number;
+ bandwidth_tb?: number;
+ spec_name?: string;
+ }
+
+ let servers: ServerWithSpecs[] = [];
+
+ if (env.CLOUD_ORCHESTRATOR) {
+ try {
+ const response = await env.CLOUD_ORCHESTRATOR.fetch(`https://internal/api/provision/orders?user_id=${telegramUserId}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (response.ok) {
+ const data = await response.json() as { orders?: ServerWithSpecs[] };
+ servers = data.orders || [];
+ }
+ } catch {
+ // API 실패 시 로컬 DB 폴백
+ }
+ }
+
+ // API 실패 시 로컬 DB에서 조회 (스펙 정보 없이)
+ if (servers.length === 0) {
+ const localServers = await env.DB
+ .prepare(`
+ SELECT id, label, status, region
+ FROM server_orders
+ WHERE telegram_user_id = ? AND status IN ('active', 'stopped', 'provisioning')
+ ORDER BY created_at DESC
+ `)
+ .bind(telegramUserId)
+ .all<{ id: number; label: string; status: string; region: string }>();
+
+ servers = localServers.results || [];
+ }
+
+ if (servers.length === 0) {
+ return `🖥️ 내 서버
+
+보유한 서버가 없습니다.
+
+"서버 추천" 또는 "서버 신청"으로 시작하세요!`;
+ }
+
+ const statusIcon: Record = {
+ active: '🟢',
+ stopped: '🔴',
+ provisioning: '🟡',
+ };
+
+ const serverList = servers
+ .filter(s => ['active', 'stopped', 'provisioning'].includes(s.status))
+ .map((s) => {
+ const icon = statusIcon[s.status] || '⚪';
+ const label = s.label || '(이름없음)';
+
+ // 스펙 정보가 있으면 표시
+ let specInfo = '';
+ if (s.vcpu && s.memory_gb) {
+ specInfo = `\n ${s.vcpu}vCPU / ${s.memory_gb}GB RAM`;
+ if (s.bandwidth_tb) {
+ specInfo += ` / ${s.bandwidth_tb}TB`;
+ }
+ }
+
+ return `#${s.id} ${icon} ${label} (${s.region})${specInfo}`;
+ }).join('\n\n');
+
+ return `🖥️ 내 서버 (${servers.filter(s => ['active', 'stopped', 'provisioning'].includes(s.status)).length}개)
+
+${serverList}
+
+서버 관리: "N번 시작/중지" 또는 "#N 재시작"`;
+ }
+
+ case '/security': {
+ return `🛡️ DDoS 방어 서비스
+
+AnvilShield - 엔터프라이즈급 DDoS 방어
+
+• L3/L4 네트워크 공격 방어
+• L7 애플리케이션 공격 방어
+• 실시간 트래픽 모니터링
+• 자동 위협 탐지 및 차단
+
+요금제:
+• Basic: 10Gbps 방어 - ₩99,000/월
+• Pro: 100Gbps 방어 - ₩299,000/월
+• Enterprise: 무제한 - 별도 문의
+
+🔜 서비스 준비 중입니다. 문의: @AnvilSupport`;
+ }
+
+ case '/phantomx': {
+ return `🔐 PhantomX VPN
+
+Xray 기반 차세대 보안 VPN
+
+• 🚀 초고속 연결 (Xray-core 엔진)
+• 👻 트래픽 위장 (탐지 우회)
+• 🌍 글로벌 서버 (한국/일본/미국/유럽)
+• 📱 멀티 디바이스 지원
+• 🔒 제로 로그 정책
+
+요금제:
+• 월간: ₩9,900/월
+• 연간: ₩79,000/년 (33% 할인)
+
+🔜 서비스 준비 중입니다. 문의: @AnvilSupport`;
+ }
+
case '/debug': {
// Admin only - exposes internal debug info
const adminId = env.DEPOSIT_ADMIN_ID ? parseInt(env.DEPOSIT_ADMIN_ID, 10) : null;
diff --git a/src/constants/index.ts b/src/constants/index.ts
index e706b1f..80528d9 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -40,7 +40,6 @@ export const MESSAGE_MARKERS = {
*/
export const KEYBOARD_TYPES = {
DOMAIN_REGISTER: 'domain_register',
- SERVER_ORDER: 'server_order',
} as const;
/**
@@ -49,18 +48,13 @@ export const KEYBOARD_TYPES = {
* Format: prefix:action:params
* Examples:
* - domain_reg:example.com:15000
- * - server_order:userId:index
- * - server_cancel:userId
+ * - domain_cancel
*/
export const CALLBACK_PREFIXES = {
DOMAIN_REGISTER: 'domain_reg',
DOMAIN_CANCEL: 'domain_cancel',
- SERVER_ORDER: 'server_order',
- SERVER_CANCEL: 'server_cancel',
CONFIRM_DOMAIN_REGISTER: 'confirm_domain_register',
CANCEL_DOMAIN_REGISTER: 'cancel_domain_register',
- SERVER_ORDER_CONFIRM: 'confirm_server_order',
- SERVER_ORDER_CANCEL: 'cancel_server_order',
DELETE_CONFIRM: 'confirm_delete',
DELETE_CANCEL: 'cancel_delete',
} as const;
diff --git a/src/index.ts b/src/index.ts
index 95f2bf7..e5e7e45 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -307,25 +307,30 @@ export default {
// ============================================================================
/**
- * 5분 이상 pending 상태인 서버 주문 자동 삭제
+ * 오래된 서버 주문 자동 삭제
+ * - pending: 10분 경과 (Queue 전송 실패 감지)
+ * - provisioning: 30분 경과 (Cloud API 느린 응답 대비)
* 실행 주기: 매 5분 (every 5 minutes)
*/
async function cleanupStalePendingServerOrders(env: Env): Promise {
- logger.info('서버 주문 정리 시작 (5분 경과)');
+ logger.info('서버 주문 정리 시작 (pending 10분, provisioning 30분 경과)');
try {
- // 5분 이상 된 pending 서버 주문 조회
+ // 10분 이상 된 pending 또는 30분 이상 된 provisioning 서버 주문 조회
+ // pending: Queue 전송 실패 감지를 위해 10분으로 설정
+ // provisioning: Cloud Orchestrator API 처리 시간을 고려하여 30분으로 설정
const staleOrders = await env.DB.prepare(
- `SELECT so.id, so.label, so.price_paid, u.telegram_id
+ `SELECT so.id, so.label, so.price_paid, so.status, u.telegram_id
FROM server_orders so
JOIN users u ON so.user_id = u.id
- WHERE so.status = 'pending'
- AND datetime(so.created_at) < datetime('now', '-5 minutes')
+ WHERE (so.status = 'pending' AND datetime(so.created_at) < datetime('now', '-10 minutes'))
+ OR (so.status = 'provisioning' AND datetime(so.created_at) < datetime('now', '-30 minutes'))
LIMIT 50`
).all<{
id: number;
label: string | null;
price_paid: number;
+ status: string;
telegram_id: string;
}>();
@@ -336,35 +341,91 @@ async function cleanupStalePendingServerOrders(env: Env): Promise {
logger.info('방치된 서버 주문 발견', { count: staleOrders.results.length });
- // 서버 주문 삭제
- const orderIds = staleOrders.results.map(order => order.id);
- await env.DB.prepare(
- `DELETE FROM server_orders WHERE id IN (${orderIds.map(() => '?').join(',')})`
- ).bind(...orderIds).run();
+ // 각 주문별 환불 + 삭제 + 알림 처리 (개별 실패 허용)
+ let successCount = 0;
+ let refundCount = 0;
- logger.info('서버 주문 삭제 완료', { count: orderIds.length });
+ for (const order of staleOrders.results) {
+ try {
+ // 1. 사용자 ID 조회 (telegram_id → user_id)
+ const user = await env.DB.prepare(
+ 'SELECT id FROM users WHERE telegram_id = ?'
+ ).bind(order.telegram_id).first<{ id: number }>();
- // 사용자 알림 병렬 처리 (개별 실패 무시)
- const notificationPromises = staleOrders.results.map(order =>
- sendMessage(
- env.BOT_TOKEN,
- parseInt(order.telegram_id),
- `❌ 서버 주문 자동 취소\n\n` +
- `주문 #${order.id}이 처리되지 않아 자동 취소되었습니다.\n` +
- `• 서버명: ${order.label || '(미지정)'}\n` +
- `• 결제 금액: ${order.price_paid.toLocaleString()}원\n\n` +
- `다시 시도해주세요.`
- ).catch(err => {
- logger.error('알림 전송 실패', err as Error, {
- orderId: order.id,
- userId: order.telegram_id
+ if (!user) {
+ logger.warn('사용자 정보 없음 - 주문만 삭제', { orderId: order.id, telegramId: order.telegram_id });
+ await env.DB.prepare('DELETE FROM server_orders WHERE id = ?').bind(order.id).run();
+ successCount++;
+ continue;
+ }
+
+ // 2. 환불 처리 (결제 금액이 있는 경우만)
+ if (order.price_paid > 0) {
+ // 2-1. 잔액 환불
+ await env.DB.prepare(
+ `UPDATE user_deposits
+ SET balance = balance + ?,
+ version = version + 1,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE user_id = ?`
+ ).bind(order.price_paid, user.id).run();
+
+ // 2-2. 환불 거래 기록
+ await env.DB.prepare(
+ `INSERT INTO deposit_transactions
+ (user_id, type, amount, status, description, confirmed_at)
+ VALUES (?, 'refund', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
+ ).bind(
+ user.id,
+ order.price_paid,
+ `서버 주문 #${order.id} 자동 취소 환불 (${order.status} 타임아웃)`
+ ).run();
+
+ refundCount++;
+ logger.info('Stale order 환불 완료', {
+ orderId: order.id,
+ userId: user.id,
+ amount: order.price_paid
+ });
+ }
+
+ // 3. 주문 삭제
+ await env.DB.prepare('DELETE FROM server_orders WHERE id = ?')
+ .bind(order.id).run();
+
+ // 4. 사용자 알림
+ const reason = order.status === 'provisioning'
+ ? '서버 생성 중 문제가 발생하여'
+ : '처리되지 않아';
+
+ await sendMessage(
+ env.BOT_TOKEN,
+ parseInt(order.telegram_id),
+ `⏰ 서버 주문 자동 취소\n\n` +
+ `주문 #${order.id}이 ${reason} 자동 취소되었습니다.\n` +
+ `• 서버명: ${order.label || '(미지정)'}\n` +
+ `• 환불 금액: ${order.price_paid.toLocaleString()}원\n\n` +
+ `다시 시도해주세요.`
+ ).catch(err => {
+ logger.error('알림 전송 실패', err as Error, {
+ orderId: order.id,
+ userId: order.telegram_id
+ });
});
- return null;
- })
- );
- await Promise.all(notificationPromises);
- logger.info('서버 주문 정리 완료', { count: staleOrders.results.length });
+ successCount++;
+ } catch (error) {
+ logger.error('Stale order 환불/삭제 실패', error as Error, { orderId: order.id });
+ }
+ }
+
+ logger.info('Stale server orders 정리 완료', {
+ total: staleOrders.results.length,
+ success: successCount,
+ refunded: refundCount,
+ pendingTimeout: '10분',
+ provisioningTimeout: '30분'
+ });
} catch (error) {
logger.error('서버 주문 정리 오류', error as Error);
}
diff --git a/src/routes/api/chat.ts b/src/routes/api/chat.ts
index c3ae59b..e70d5f7 100644
--- a/src/routes/api/chat.ts
+++ b/src/routes/api/chat.ts
@@ -110,6 +110,60 @@ async function handleTestApi(request: Request, env: Env): Promise {
// 사용자 조회/생성
const userId = await getOrCreateUser(env.DB, telegramUserId, 'TestUser', 'testuser');
+ // 서버 삭제 확인 처리 (텍스트 기반)
+ if (body.text.trim() === '삭제') {
+ const deleteSessionKey = `delete_confirm:${telegramUserId}`;
+ const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
+
+ if (deleteSessionData) {
+ try {
+ const { orderId } = JSON.parse(deleteSessionData);
+
+ // Import and execute server deletion
+ const { executeServerDelete } = await import('../../tools/server-tool');
+ const result = await executeServerDelete(orderId, telegramUserId, env);
+
+ // Delete session after execution
+ await env.SESSION_KV.delete(deleteSessionKey);
+
+ return Response.json({
+ input: body.text,
+ response: result.message,
+ user_id: telegramUserId,
+ });
+ } catch (error) {
+ logger.error('Test API - 서버 삭제 처리 오류', toError(error));
+ return Response.json({
+ input: body.text,
+ response: '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.',
+ user_id: telegramUserId,
+ });
+ }
+ }
+ }
+
+ // 서버 삭제 취소 처리 (다른 메시지 입력 시)
+ const deleteSessionKey = `delete_confirm:${telegramUserId}`;
+ const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
+
+ if (deleteSessionData && body.text.trim() !== '삭제') {
+ try {
+ const { label } = JSON.parse(deleteSessionData);
+ await env.SESSION_KV.delete(deleteSessionKey);
+
+ // Don't show cancellation message if it's a command
+ if (!body.text.startsWith('/')) {
+ return Response.json({
+ input: body.text,
+ response: `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`,
+ user_id: telegramUserId,
+ });
+ }
+ } catch (error) {
+ logger.error('Test API - 삭제 세션 취소 오류', toError(error));
+ }
+ }
+
let responseText: string;
// 명령어 처리
@@ -133,11 +187,8 @@ async function handleTestApi(request: Request, env: Env): Promise {
// 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
- // 4. 임계값 도달시 프로필 업데이트
- const { summarized } = await processAndSummarize(env, userId, chatIdStr);
- if (summarized) {
- responseText += '\n\n👤 프로필이 업데이트되었습니다.';
- }
+ // 4. 임계값 도달시 프로필 업데이트 (백그라운드)
+ await processAndSummarize(env, userId, chatIdStr);
}
// HTML 태그 제거 (CLI 출력용)
@@ -432,11 +483,8 @@ async function handleChatApi(request: Request, env: Env): Promise {
// 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
- // 4. 임계값 도달시 프로필 업데이트
- const { summarized } = await processAndSummarize(env, userId, chatIdStr);
- if (summarized) {
- responseText += '\n\n👤 프로필이 업데이트되었습니다.';
- }
+ // 4. 임계값 도달시 프로필 업데이트 (백그라운드)
+ await processAndSummarize(env, userId, chatIdStr);
}
const processingTimeMs = Date.now() - startTime;
diff --git a/src/routes/handlers/callback-handler.ts b/src/routes/handlers/callback-handler.ts
index 6b71301..16ac2e5 100644
--- a/src/routes/handlers/callback-handler.ts
+++ b/src/routes/handlers/callback-handler.ts
@@ -157,213 +157,6 @@ ${result.error}
return;
}
- // 서버 주문 확인
- if (data.startsWith(`${CALLBACK_PREFIXES.SERVER_ORDER}:`)) {
- const parts = data.split(':');
- if (parts.length !== 3) {
- await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
- return;
- }
-
- const callbackUserId = parts[1];
- const index = parseInt(parts[2], 10);
-
- // SECURITY: Verify callback userId matches the actual user
- if (callbackUserId !== telegramUserId) {
- await answerCallbackQuery(env.BOT_TOKEN, queryId, {
- text: '⚠️ 권한이 없습니다.',
- show_alert: true
- });
- return;
- }
-
- if (isNaN(index) || index < 0 || index > 2) {
- await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
- return;
- }
-
- await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '처리 중...' });
- await editMessageText(
- env.BOT_TOKEN,
- chatId,
- messageId,
- '⏳ 서버 주문 처리 중...'
- );
-
- try {
- // 세션 조회
- const { getServerSession, deleteServerSession } = await import('../../server-agent');
-
- if (!env.DB) {
- await editMessageText(
- env.BOT_TOKEN,
- chatId,
- messageId,
- '❌ 세션 저장소가 설정되지 않았습니다.'
- );
- return;
- }
-
- // Use verified telegramUserId instead of callback userId
- const session = await getServerSession(env.DB, telegramUserId);
-
- if (!session || !session.lastRecommendation) {
- await editMessageText(
- env.BOT_TOKEN,
- chatId,
- messageId,
- '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.'
- );
- return;
- }
-
- const selected = session.lastRecommendation.recommendations[index];
-
- if (!selected) {
- await editMessageText(
- env.BOT_TOKEN,
- chatId,
- messageId,
- '❌ 선택한 서버를 찾을 수 없습니다.'
- );
- await deleteServerSession(env.DB, telegramUserId);
- return;
- }
-
- // 잔액 확인
- const deposit = await env.DB.prepare(
- 'SELECT balance FROM user_deposits WHERE user_id = ?'
- ).bind(user.id).first<{ balance: number }>();
-
- const price = selected.price?.monthly_krw || 0;
-
- if (!deposit || deposit.balance < price) {
- await editMessageText(
- env.BOT_TOKEN, chatId, messageId,
- `❌ 잔액이 부족합니다.
-
-• 서버 가격: ${price.toLocaleString()}원/월
-• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원
-• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원
-
-잔액을 충전 후 다시 시도해주세요.`
- );
- return;
- }
-
- // Queue 확인
- if (!env.SERVER_PROVISION_QUEUE) {
- await editMessageText(
- env.BOT_TOKEN, chatId, messageId,
- '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.'
- );
- return;
- }
-
- // 주문 생성 (DB INSERT)
- const { createServerOrder, sendProvisionMessage } = await import('../../server-provision');
-
- const orderId = await createServerOrder(
- env.DB,
- user.id,
- telegramUserId,
- selected.pricing_id,
- selected.region.code,
- 'anvil',
- price,
- `${selected.plan_name} - ${session.collectedInfo?.useCase || 'server'}`
- );
-
- // Queue에 메시지 전송
- await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, user.id, telegramUserId);
-
- // 즉시 응답
- await editMessageText(
- env.BOT_TOKEN,
- chatId,
- messageId,
- `📋 서버 주문 접수 완료! (주문 #${orderId})
-
-• 서버: ${selected.plan_name}
-• 리전: ${selected.region.name} (${selected.region.code})
-• 가격: ${price.toLocaleString()}원/월
-
-⏳ 서버를 생성하고 있습니다... (1-2분 소요)
-완료되면 메시지로 알려드릴게요.`
- );
-
- // 세션 삭제
- await deleteServerSession(env.DB, telegramUserId);
- } catch (error) {
- logger.error('서버 주문 처리 실패', error as Error, {
- index,
- userId: user.id,
- telegramUserId
- });
-
- await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '❌ 처리 중 오류 발생' });
-
- await editMessageText(
- env.BOT_TOKEN,
- chatId,
- messageId,
- `❌ 처리 중 오류 발생
-
-서버 주문 처리 중 예상치 못한 오류가 발생했습니다.
-잠시 후 다시 시도해주세요.
-
-문제가 계속되면 관리자에게 문의해주세요.`
- );
-
- // Fallback: send as new message if editMessageText fails
- await sendMessage(
- env.BOT_TOKEN,
- chatId,
- '❌ 서버 주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
- ).catch(e => logger.error('Fallback message send failed', e as Error));
- }
- return;
- }
-
- // 서버 주문 취소
- if (data.startsWith(`${CALLBACK_PREFIXES.SERVER_CANCEL}:`)) {
- const parts = data.split(':');
- if (parts.length !== 2) {
- await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
- return;
- }
-
- const callbackUserId = parts[1];
-
- // SECURITY: Verify callback userId matches the actual user
- if (callbackUserId !== telegramUserId) {
- await answerCallbackQuery(env.BOT_TOKEN, queryId, {
- text: '⚠️ 권한이 없습니다.',
- show_alert: true
- });
- return;
- }
-
- await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
- await editMessageText(
- env.BOT_TOKEN,
- chatId,
- messageId,
- '❌ 서버 신청이 취소되었습니다.'
- );
-
- // 세션 삭제
- const { deleteServerSession } = await import('../../server-agent');
-
- if (env.DB) {
- await deleteServerSession(env.DB, telegramUserId);
- }
-
- return;
- }
-
- // Note: server_delete callback handler removed - now using text-based confirmation
-
// 알 수 없는 callback data
await answerCallbackQuery(env.BOT_TOKEN, queryId);
}
diff --git a/src/routes/handlers/message-handler.ts b/src/routes/handlers/message-handler.ts
index fc0aa38..2d10280 100644
--- a/src/routes/handlers/message-handler.ts
+++ b/src/routes/handlers/message-handler.ts
@@ -244,10 +244,7 @@ export async function handleMessage(
telegramUserId
);
- let finalResponse = result.responseText;
- if (result.isProfileUpdated) {
- finalResponse += '\n\n👤 프로필이 업데이트되었습니다.';
- }
+ const finalResponse = result.responseText;
// 10. 응답 전송 (키보드 포함 여부 확인)
if (result.keyboardData) {
@@ -262,19 +259,8 @@ export async function handleMessage(
{ text: '❌ 취소', callback_data: 'domain_cancel' }
]
]);
- } else if (result.keyboardData.type === 'server_order') {
- const { userId, index } = result.keyboardData;
- const confirmData = `server_order:${userId}:${index}`;
- const cancelData = `server_cancel:${userId}`;
-
- await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [
- [
- { text: '✅ 신청하기', callback_data: confirmData },
- { text: '❌ 취소', callback_data: cancelData }
- ]
- ]);
} else {
- // TypeScript exhaustiveness check - should never reach here
+ // Unknown keyboard type - just send as regular message
logger.warn('Unknown keyboard type', { type: (result.keyboardData as { type: string }).type });
await sendMessage(env.BOT_TOKEN, chatId, finalResponse);
}
diff --git a/src/server-agent.ts b/src/server-agent.ts
index dbe6a4e..4689d4c 100644
--- a/src/server-agent.ts
+++ b/src/server-agent.ts
@@ -436,11 +436,11 @@ ${session.collectedInfo.budgetLimit ? `- 예산: ${session.collectedInfo.budgetL
## 도구 사용 가이드 (적극적으로 활용할 것)
- 고객이 특정 프레임워크/기술을 언급하면 (예: Next.js, Laravel, Django, Astro, Bun, Rust 등) → 반드시 lookup_framework_docs 호출하여 최신 공식 권장 스펙 확인
- "최신", "트렌드", "2024", "2025", "요즘" 등 시의성 있는 키워드 → 반드시 search_trends 호출
-- 블로그, 쇼핑몰 같은 일반적 용도는 경험으로 바로 답변
+- SaaS, 모바일 앱 백엔드 같은 일반적 용도는 경험으로 바로 답변
- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...")
## 대화 흐름
-1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: 블로그, 쇼핑몰, 커뮤니티)"
+1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: SaaS, 앱 백엔드, AI 서비스)"
2. 규모 파악: "개인용인가요, 사업용인가요?"
3. 사용자 수 확인 (필요 시): "방문자나 사용자 수는 어느 정도 예상하시나요?"
4. 정보가 충분하면 즉시 추천 (추가 질문 없이)
@@ -691,7 +691,7 @@ export async function processServerConsultation(
updatedAt: Date.now()
};
await saveServerSession(env.DB, session.telegramUserId, newSession);
- return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n예: 블로그, 쇼핑몰, 커뮤니티, API 서버 등';
+ return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
}
// 선택 단계 처리
diff --git a/src/server-provision.ts b/src/server-provision.ts
index 6699b44..57a7c8f 100644
--- a/src/server-provision.ts
+++ b/src/server-provision.ts
@@ -95,6 +95,28 @@ async function deleteServerOrder(db: D1Database, orderId: number): Promise
logger.info('서버 주문 삭제', { orderId });
}
+/**
+ * 잔액 사전 확인 (프로비저닝 전에 실행)
+ */
+async function checkBalance(
+ db: D1Database,
+ userId: number,
+ requiredAmount: number
+): Promise<{ sufficient: boolean; currentBalance: number }> {
+ const result = await db.prepare(
+ 'SELECT balance FROM user_deposits WHERE user_id = ?'
+ ).bind(userId).first<{ balance: number }>();
+
+ if (!result) {
+ return { sufficient: false, currentBalance: 0 };
+ }
+
+ return {
+ sufficient: result.balance >= requiredAmount,
+ currentBalance: result.balance
+ };
+}
+
/**
* 잔액 차감 (Optimistic Locking 적용)
*/
@@ -247,6 +269,67 @@ async function callCloudOrchestrator(
return data;
}
+/**
+ * 프로비저닝된 서버 삭제 (수동 서버 삭제 기능에서 사용 예정)
+ * 현재는 사전 잔액 확인으로 롤백이 필요 없지만, 향후 활용 가능
+ */
+// @ts-expect-error - Preserved for future manual server deletion feature
+async function deleteProvisionedServer(
+ orchestrator: Fetcher | undefined,
+ apiKey: string | undefined,
+ orderId: number,
+ userId: string
+): Promise<{ success: boolean; error?: string }> {
+ if (!orchestrator) {
+ return { success: false, error: 'CLOUD_ORCHESTRATOR Service Binding not configured' };
+ }
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
+
+ // API 키 추가 (필수)
+ if (apiKey) {
+ headers['X-API-Key'] = apiKey;
+ }
+
+ logger.info('서버 삭제 API 호출 (결제 실패 롤백)', { orderId, userId });
+
+ try {
+ const response = await orchestrator.fetch(
+ `https://internal/api/provision/orders/${orderId}?user_id=${userId}`,
+ {
+ method: 'DELETE',
+ headers
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ logger.error('서버 삭제 실패', new Error(errorText), {
+ orderId,
+ status: response.status
+ });
+ return { success: false, error: `HTTP ${response.status}: ${errorText}` };
+ }
+
+ const data = await response.json() as ProvisionResponse;
+
+ if (!data.success) {
+ logger.error('서버 삭제 실패 (API 응답)', new Error(data.error || 'Unknown error'), {
+ orderId
+ });
+ return { success: false, error: data.error || 'Deletion failed' };
+ }
+
+ logger.info('서버 삭제 성공', { orderId });
+ return { success: true };
+ } catch (error) {
+ logger.error('서버 삭제 API 호출 중 예외', error as Error, { orderId });
+ return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
+ }
+}
+
/**
* DB에 서버 주문 생성
* @returns order_id
@@ -379,6 +462,29 @@ export async function handleProvisionQueue(
await updateOrderStatus(env.DB, order_id, 'provisioning');
}
+ // 2.5. 잔액 사전 확인 (VM 생성 전에 체크)
+ const balanceCheck = await checkBalance(env.DB, user_id, order.price_paid);
+ if (!balanceCheck.sufficient) {
+ logger.warn('잔액 부족으로 프로비저닝 취소', {
+ orderId: order_id,
+ required: order.price_paid,
+ current: balanceCheck.currentBalance
+ });
+
+ await updateOrderStatus(env.DB, order_id, 'failed', {
+ error_message: '잔액 부족'
+ });
+
+ await notifyUser(
+ env.BOT_TOKEN,
+ telegram_user_id,
+ `❌ 서버 생성 실패\n\n잔액이 부족합니다.\n• 필요 금액: ${order.price_paid.toLocaleString()}원\n• 현재 잔액: ${balanceCheck.currentBalance.toLocaleString()}원\n\n입금 후 다시 시도해주세요.`
+ );
+
+ message.ack();
+ continue;
+ }
+
// 3. Cloud Orchestrator API 호출
try {
const provisionResult = await callCloudOrchestrator(
@@ -404,27 +510,27 @@ export async function handleProvisionQueue(
`서버 주문 #${order_id} - ${order.label || order.spec_id}`
);
} catch (balanceError) {
- // 잔액 차감 실패 시 - 서버는 생성됐지만 결제 실패
- // 이 경우 관리자 알림 필요 (서버는 수동 삭제 필요)
- logger.error('잔액 차감 실패 (서버는 생성됨)', balanceError as Error, {
+ // 잔액 차감 실패 (이론상 불가능 - 사전 확인했으므로)
+ // Race condition으로 발생 가능, 수동 개입 필요
+ logger.error('잔액 차감 실패 (예상치 못한 에러)', balanceError as Error, {
orderId: order_id,
- userId: user_id
+ userId: user_id,
+ amount: order.price_paid
});
- // 주문 상태는 active로 변경하되, 결제 실패 표시
+ // 주문 상태 업데이트
await updateOrderStatus(env.DB, order_id, 'active', {
- provider_instance_id: provisionResult.order.provider_instance_id || undefined,
+ provider_instance_id: provisionResult.order.provider_instance_id,
ip_address: provisionResult.order.ip_address || undefined,
- // root_password는 Cloud Orchestrator가 이미 DB에 저장함 - 덮어쓰지 않음
provisioned_at: new Date().toISOString(),
- error_message: '결제 실패 - 관리자 확인 필요'
+ error_message: `결제 실패 - 관리자 확인 필요: ${balanceError instanceof Error ? balanceError.message : 'Unknown error'}`
});
- // 관리자 알림
+ // 관리자 긴급 알림
await notifyAdmin(
env.BOT_TOKEN,
env.DEPOSIT_ADMIN_ID,
- `🚨 결제 실패 알림\n\n주문 #${order_id}\n서버는 생성됐으나 잔액 차감 실패\n사용자: ${telegram_user_id}\n금액: ${order.price_paid.toLocaleString()}원\n\n수동 처리 필요`
+ `🚨 긴급: 서버 생성 후 결제 실패\n\n주문 #${order_id}\n사용자: ${telegram_user_id}\n금액: ${order.price_paid.toLocaleString()}원\n\n서버는 생성되었으나 잔액 차감 실패 (Race condition)\nIP: ${provisionResult.order.ip_address || 'N/A'}\n\n수동 처리 필요!`
);
// 사용자 알림
@@ -439,21 +545,30 @@ export async function handleProvisionQueue(
}
// 5. 성공 시 DB 업데이트
- // Note: root_password는 Cloud Orchestrator가 생성하여 DB에 저장함
- // API 응답의 root_password는 마스킹된 값이므로 업데이트하지 않음
- await updateOrderStatus(env.DB, order_id, 'active', {
- provider_instance_id: provisionResult.order.provider_instance_id || undefined,
- ip_address: provisionResult.order.ip_address || undefined,
- // root_password는 Cloud Orchestrator가 이미 DB에 저장함 - 덮어쓰지 않음
- provisioned_at: new Date().toISOString()
- });
+ // Cloud Orchestrator는 비동기로 실제 프로비저닝을 수행하고 완료 시 status='active'로 업데이트함
+ // telegram-bot-workers는 provider_instance_id가 있을 때만 active로 설정
+ // (없으면 cloud-orchestrator의 Queue가 아직 처리 중인 것)
+ if (provisionResult.order.provider_instance_id) {
+ await updateOrderStatus(env.DB, order_id, 'active', {
+ provider_instance_id: provisionResult.order.provider_instance_id,
+ ip_address: provisionResult.order.ip_address || undefined,
+ provisioned_at: new Date().toISOString()
+ });
+ logger.info('프로비저닝 완료 (즉시)', {
+ orderId: order_id,
+ providerInstanceId: provisionResult.order.provider_instance_id
+ });
+ } else {
+ // provider_instance_id가 없으면 Cloud Orchestrator Queue가 처리 중
+ // status는 'provisioning' 유지, cloud-orchestrator가 완료 후 active로 변경
+ logger.info('프로비저닝 요청 완료 - Cloud Orchestrator Queue에서 처리 중', {
+ orderId: order_id,
+ currentStatus: 'provisioning'
+ });
+ }
// 6. 사용자 알림은 Cloud Orchestrator에서 처리
// (실제 IP와 비밀번호가 할당된 후 전송)
- logger.info('프로비저닝 요청 완료 - Cloud Orchestrator에서 알림 처리', {
- orderId: order_id,
- providerInstanceId: provisionResult.order.provider_instance_id
- });
message.ack();
logger.info('프로비저닝 완료', { orderId: order_id });
diff --git a/src/summary-service.ts b/src/summary-service.ts
index 4911b25..2463055 100644
--- a/src/summary-service.ts
+++ b/src/summary-service.ts
@@ -396,6 +396,11 @@ ${memoriesSection}
- 서버, VPS, 클라우드, 호스팅 관련 요청:
• 내 서버 목록 조회: manage_server(action="list") - 반드시 도구 호출
• 서버 추천/상담 시작: manage_server(action="start_consultation")
+ • 서버 이름 변경: manage_server(action="rename", order_id=번호, new_label="새이름")
+ • 서버 시작: manage_server(action="start", order_id=번호) - 반드시 도구 호출. "N번 시작", "#N 시작" 등
+ • 서버 중지: manage_server(action="stop", order_id=번호) - 반드시 도구 호출. "N번 중지", "#N 정지" 등
+ • 서버 재시작: manage_server(action="reboot", order_id=번호) - 반드시 도구 호출. "N번 재시작", "#N 리부팅" 등
+ • 서버 삭제/해지: manage_server(action="delete", order_id=번호) - 반드시 도구 호출. "N번 삭제/해지/취소", "#N 해지" 등. 만료일과 무관하게 즉시 삭제 가능하며, 삭제를 거부하지 마세요.
• 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요)
- 기술 문제, 에러, 오류, 장애 관련 요청:
• "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시
diff --git a/src/tools/index.ts b/src/tools/index.ts
index c323312..9ebd18d 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -65,8 +65,8 @@ const RedditSearchArgsSchema = z.object({
});
const ManageServerArgsSchema = z.object({
- action: z.enum(['recommend', 'order', 'start', 'stop', 'delete', 'list', 'info', 'images',
- 'start_consultation', 'continue_consultation', 'cancel_consultation']),
+ action: z.enum(['recommend', 'order', 'start', 'stop', 'reboot', 'delete', 'list', 'info', 'images',
+ 'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename']),
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(),
@@ -79,7 +79,8 @@ const ManageServerArgsSchema = z.object({
label: z.string().min(1).max(100).optional(),
message: z.string().min(1).max(500).optional(), // For continue_consultation
pricing_id: z.number().int().positive().optional(), // For order
- order_id: z.number().int().positive().optional(), // For info, delete
+ order_id: z.number().int().positive().optional(), // For info, delete, rename
+ new_label: z.string().min(1).max(100).optional(), // For rename
image: z.string().min(1).max(50).optional(), // For order (OS image)
});
diff --git a/src/tools/server-tool.ts b/src/tools/server-tool.ts
index ff9e332..229cb81 100644
--- a/src/tools/server-tool.ts
+++ b/src/tools/server-tool.ts
@@ -13,6 +13,14 @@ import { formatTrafficInfo } from '../utils/formatters';
const logger = createLogger('server-tool');
const provisionLogger = createLogger('provision');
+// Generate idempotency key for order requests
+// Format: tg-order-{userId}-{timestamp}-{random}
+function generateIdempotencyKey(userId: string): string {
+ const timestamp = Date.now();
+ const random = Math.random().toString(36).substring(2, 10);
+ return `tg-order-${userId}-${timestamp}-${random}`;
+}
+
// CDN 캐시 히트율 상수
const CDN_CACHE_HIT_RATES = {
VIDEO_STREAMING: 0.92,
@@ -62,19 +70,34 @@ function isErrorResult(result: unknown): result is { error: string } {
return typeof result === 'object' && result !== null && 'error' in result;
}
+// 진행 중인 주문 확인 (중복 주문 방지)
+async function checkExistingOrder(
+ db: D1Database,
+ telegramUserId: string
+): Promise<{ id: number; status: string; label: string | null } | null> {
+ const result = await db.prepare(
+ `SELECT id, status, label FROM server_orders
+ WHERE telegram_user_id = ?
+ AND status IN ('pending', 'provisioning')
+ LIMIT 1`
+ ).bind(telegramUserId).first<{ id: number; status: string; label: string | null }>();
+
+ return result || null;
+}
+
export const manageServerTool = {
type: 'function',
function: {
name: 'manage_server',
- description: '클라우드 서버 관리 및 추천. 서버/VPS/클라우드/호스팅 관련 요청 시 반드시 사용. 내 서버 목록: action="list", 서버 추천(용도/규모 알면): action="recommend", 서버 추천(정보 부족): action="start_consultation"',
+ description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 추천(action="start_consultation"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
- enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop',
- 'start_consultation', 'continue_consultation', 'cancel_consultation'],
- description: 'recommend: 서버 추천 (용도/규모 파악됨), start_consultation: 상담 시작 (정보 부족), list: 내 서버 목록, info: 서버 상세, images: OS 목록',
+ enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot',
+ 'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename'],
+ description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, start_consultation: 상담 시작, rename: 이름 변경',
},
tech_stack: {
type: 'array',
@@ -130,7 +153,11 @@ export const manageServerTool = {
},
order_id: {
type: 'number',
- description: '주문 번호. info, delete action에서 필수',
+ description: '주문 번호. info, delete, rename action에서 필수',
+ },
+ new_label: {
+ type: 'string',
+ description: '새 서버 이름. rename action에서 필수',
},
image: {
type: 'string',
@@ -234,22 +261,22 @@ async function callProvisionAPI(
body: body ? JSON.stringify(body) : undefined,
};
- // Add userId as query param for GET/DELETE
+ // Add userId as query param for GET/DELETE/POST
let fullEndpoint = endpoint;
- if ((method === 'GET' || method === 'DELETE') && userId && !endpoint.includes('?')) {
+ if (userId && !endpoint.includes('?')) {
fullEndpoint = `${endpoint}?user_id=${userId}`;
- } else if ((method === 'GET' || method === 'DELETE') && userId) {
+ } else if (userId && endpoint.includes('?')) {
fullEndpoint = `${endpoint}&user_id=${userId}`;
}
- // Service Binding 우선, fallback: URL
+ // Service Binding 우선, fallback: HTTP
if (env?.CLOUD_ORCHESTRATOR) {
provisionLogger.info('Service Binding 사용', { endpoint: fullEndpoint });
return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${fullEndpoint}`, requestInit);
} else {
const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev';
const url = `${apiUrl}${fullEndpoint}`;
- provisionLogger.info('HTTP 요청 사용', { url });
+ provisionLogger.info('HTTP 요청 사용 (fallback)', { url });
return fetch(url, requestInit);
}
},
@@ -262,9 +289,24 @@ async function callProvisionAPI(
endpoint,
status: response.status,
});
+
+ // JSON 응답에서 오류 메시지 추출
+ let errorMessage = `HTTP ${response.status}`;
+ try {
+ const errorJson = JSON.parse(errorText);
+ if (errorJson.error) {
+ errorMessage = errorJson.error;
+ }
+ } catch {
+ // JSON 파싱 실패 시 텍스트 그대로 사용
+ if (errorText && errorText.length < 200) {
+ errorMessage = errorText;
+ }
+ }
+
return {
success: false,
- error: `프로비저닝 API 호출 실패: HTTP ${response.status}`,
+ error: `프로비저닝 API 호출 실패: ${errorMessage}`,
};
}
@@ -394,13 +436,16 @@ function getStatusEmoji(status: string): string {
case 'active':
return '🟢';
case 'provisioning':
- return '🟡';
+ return '🔄';
case 'stopped':
- return '🔴';
+ return '⛔';
case 'deleted':
- return '⚫';
+ case 'terminated':
+ return '🗑️';
case 'failed':
return '❌';
+ case 'pending':
+ return '⏳'; // 내부용, UI에 표시 안 함
default:
return '⚪';
}
@@ -412,10 +457,11 @@ function getStatusText(status: string): string {
case 'active':
return '가동 중';
case 'provisioning':
- return '생성 중';
+ return '생성 중...';
case 'stopped':
return '중지됨';
case 'deleted':
+ case 'terminated':
return '삭제됨';
case 'failed':
return '실패';
@@ -476,10 +522,27 @@ function formatExpiry(expiresAt: string): string {
// 서버 목록 포맷팅
function formatServerList(orders: ProvisionOrder[]): string {
- // 활성 상태만 표시 (terminated 제외)
- const activeOrders = orders?.filter(order =>
- ['pending', 'provisioning', 'active'].includes(order.status)
- ) || [];
+ // 'pending' 상태는 내부용 (Queue 대기), UI에 표시하지 않음
+ // 'provisioning' 또는 'active' 상태만 표시
+ // 'terminated', 'deleted', 'failed' 상태는 제외
+ const activeOrders = orders?.filter(order => {
+ // pending은 표시하지 않음 (내부 Queue 상태)
+ if (order.status === 'pending') {
+ return false;
+ }
+
+ // provisioning 또는 active만 표시
+ if (!['provisioning', 'active'].includes(order.status)) {
+ return false;
+ }
+
+ // active 상태인데 provider_instance_id가 없으면 제외 (프로비저닝 실패)
+ if (order.status === 'active' && !order.provider_instance_id) {
+ return false;
+ }
+
+ return true;
+ }) || [];
if (activeOrders.length === 0) {
return '🖥️ 등록된 서버가 없습니다.\n\n서버를 추천받으려면 "서버 추천"이라고 말씀해주세요.';
@@ -487,16 +550,16 @@ function formatServerList(orders: ProvisionOrder[]): string {
let response = '__DIRECT__\n🖥️ 내 서버 목록\n\n';
- activeOrders.forEach((order, index) => {
- const emoji = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'][index] || '▪️';
+ activeOrders.forEach((order) => {
const statusEmoji = getStatusEmoji(order.status);
const statusText = getStatusText(order.status);
+ const label = order.label || '(라벨 없음)';
- response += `${emoji} ${order.label} (#${order.id})\n`;
+ response += `#${order.id} ${statusEmoji} ${label}\n`;
if (order.ip_address) {
response += ` • IP: ${order.ip_address}\n`;
}
- response += ` • 상태: ${statusEmoji} ${statusText}\n`;
+ response += ` • 상태: ${statusText}\n`;
response += ` • 생성일: ${formatDate(order.created_at)}\n`;
// 만료일 표시 (있을 경우)
@@ -509,6 +572,8 @@ function formatServerList(orders: ProvisionOrder[]): string {
response += '\n';
});
+ response += '💡 서버 관리: "N번 시작/중지" 또는 "#N 재시작"';
+
return response.trim();
}
@@ -584,6 +649,7 @@ export async function executeServerAction(
message?: string;
pricing_id?: number;
order_id?: number;
+ new_label?: string;
image?: string;
},
env?: Env,
@@ -621,7 +687,7 @@ export async function executeServerAction(
logger.info('상담 세션 생성', { userId: maskUserId(telegramUserId) });
- return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n예: 블로그, 쇼핑몰, 커뮤니티, API 서버 등';
+ return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
}
case 'continue_consultation': {
@@ -793,6 +859,19 @@ export async function executeServerAction(
return '🚫 환경 설정이 필요합니다.';
}
+ // 중복 주문 방지: 진행 중인 주문 확인
+ if (env.DB) {
+ const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
+ if (existingOrder) {
+ const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
+ return `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
+ `• 주문 번호: #${existingOrder.id}\n` +
+ `• 라벨: ${existingOrder.label || '없음'}\n` +
+ `• 상태: ${statusText}\n\n` +
+ `완료 후 다시 시도해주세요.`;
+ }
+ }
+
// Check balance first
const balanceResult = await callProvisionAPI(
'/api/provision/balance',
@@ -809,11 +888,15 @@ export async function executeServerAction(
// Get pricing info to check if balance is sufficient
// For now, we'll proceed with the order and let the API handle balance validation
+ // Generate idempotency key to prevent duplicate orders on network retries
+ const idempotencyKey = generateIdempotencyKey(telegramUserId);
+
const orderBody: Record = {
user_id: telegramUserId,
pricing_id,
label,
dry_run: false,
+ idempotency_key: idempotencyKey,
};
if (image) {
@@ -829,15 +912,17 @@ export async function executeServerAction(
);
if (result.error || !result.order) {
- // Check if it's a balance error
- if (result.error && result.error.includes('balance')) {
+ // Check if it's a balance error (error_code first, then fallback to text matching)
+ if (result.error_code === 'INSUFFICIENT_BALANCE' ||
+ (result.error && result.error.includes('balance'))) {
return `⚠️ 잔액이 부족합니다\n\n• 현재 잔액: ₩${balanceResult.balance_krw.toLocaleString()}\n\n${DEPOSIT_ACCOUNT_INFO}`;
}
return `🚫 서버 주문 실패: ${result.error || '알 수 없는 오류'}`;
}
const order = result.order;
- const response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`;
+ const statusEmoji = getStatusEmoji(order.status);
+ const response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${statusEmoji} ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`;
return response;
}
@@ -908,6 +993,39 @@ export async function executeServerAction(
return `__DIRECT__\n✅ 서버 중지 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 중지 중...\n\n⏳ 서버가 중지되기까지 1-2분 소요될 수 있습니다.`;
}
+ case 'reboot': {
+ const { order_id } = args;
+
+ if (!order_id) {
+ return '🚫 서버 재시작에는 order_id가 필요합니다.';
+ }
+
+ if (!telegramUserId) {
+ return '🚫 사용자 인증이 필요합니다.';
+ }
+
+ if (!env) {
+ return '🚫 환경 설정이 필요합니다.';
+ }
+
+ // Call the provision API to reboot the server
+ const result = await callProvisionAPI(
+ `/api/provision/orders/${order_id}/reboot`,
+ 'POST',
+ env,
+ undefined,
+ telegramUserId
+ );
+
+ if (result.error) {
+ return `🚫 서버 재시작 실패: ${result.error}`;
+ }
+
+ logger.info('서버 재시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
+
+ return `__DIRECT__\n✅ 서버 재시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 재시작 중...\n\n⏳ 서버가 재시작되기까지 2-3분 소요될 수 있습니다.`;
+ }
+
case 'list': {
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
@@ -1055,6 +1173,48 @@ export async function executeServerAction(
return formatImageList(result.images);
}
+ case 'rename': {
+ const { order_id, new_label } = args;
+
+ if (!telegramUserId) {
+ return '🚫 사용자 인증이 필요합니다.';
+ }
+
+ if (!order_id) {
+ return '🚫 이름 변경할 서버 번호를 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
+ }
+
+ if (!new_label) {
+ return '🚫 새 서버 이름을 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
+ }
+
+ if (!env || !env.DB) {
+ return '🚫 환경 설정이 필요합니다.';
+ }
+
+ // 서버 소유권 확인 및 이름 변경
+ const server = await env.DB.prepare(
+ `SELECT id, label FROM server_orders WHERE id = ? AND telegram_user_id = ?`
+ ).bind(order_id, telegramUserId).first<{ id: number; label: string | null }>();
+
+ if (!server) {
+ return '🚫 해당 서버를 찾을 수 없거나 권한이 없습니다.';
+ }
+
+ const oldLabel = server.label || `서버 #${server.id}`;
+
+ await env.DB.prepare(
+ `UPDATE server_orders SET label = ?, updated_at = datetime('now') WHERE id = ?`
+ ).bind(new_label, order_id).run();
+
+ logger.info('서버 이름 변경', { orderId: order_id, oldLabel, newLabel: new_label, userId: maskUserId(telegramUserId) });
+
+ return `✅ 서버 이름이 변경되었습니다.
+
+• 이전: ${oldLabel}
+• 변경: ${new_label}`;
+ }
+
default:
return `🚫 알 수 없는 작업: ${action}`;
}
@@ -1148,6 +1308,12 @@ export async function executeServerDelete(
).bind(telegramUserId).first<{ id: number }>();
if (userResult) {
+ // Get balance before refund
+ const balanceBefore = await env.DB.prepare(
+ 'SELECT balance FROM user_deposits WHERE user_id = ?'
+ ).bind(userResult.id).first<{ balance: number }>();
+ const beforeBalance = balanceBefore?.balance ?? 0;
+
// Add refund to user_deposits (with version increment for optimistic locking)
await env.DB.prepare(`
UPDATE user_deposits
@@ -1161,9 +1327,10 @@ export async function executeServerDelete(
VALUES (?, 'refund', ?, 'confirmed', ?, '시스템', datetime('now'), datetime('now'))
`).bind(userResult.id, refundAmount, `서버 해지 환불: ${orderLabel}`).run();
- refundMessage = `\n\n💰 환불 정보\n• 결제 금액: ${pricePaid.toLocaleString()}원\n• 사용 시간: ${usedHours}시간\n• 환불 금액: ${refundAmount.toLocaleString()}원`;
+ const afterBalance = beforeBalance + refundAmount;
+ refundMessage = `\n\n💰 환불 정보\n• 환불 전 잔액: ${beforeBalance.toLocaleString()}원\n• 환불 금액: +${refundAmount.toLocaleString()}원\n• 환불 후 잔액: ${afterBalance.toLocaleString()}원`;
- provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours });
+ provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours, beforeBalance, afterBalance });
}
} catch (refundError) {
provisionLogger.error('환불 처리 실패', refundError as Error, { orderId, refundAmount });
@@ -1208,6 +1375,25 @@ export async function executeServerOrder(
pricingId: orderData.pricingId,
});
+ // 중복 주문 방지: 진행 중인 주문 확인
+ if (env.DB) {
+ const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
+ if (existingOrder) {
+ const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
+ return {
+ success: false,
+ message: `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
+ `• 주문 번호: #${existingOrder.id}\n` +
+ `• 라벨: ${existingOrder.label || '없음'}\n` +
+ `• 상태: ${statusText}\n\n` +
+ `완료 후 다시 시도해주세요.`,
+ };
+ }
+ }
+
+ // Generate idempotency key to prevent duplicate orders on network retries
+ const idempotencyKey = generateIdempotencyKey(telegramUserId);
+
// Call provision API
const result = await callProvisionAPI(
'/api/provision',
@@ -1217,6 +1403,7 @@ export async function executeServerOrder(
user_id: telegramUserId,
pricing_id: orderData.pricingId,
label: orderData.label,
+ idempotency_key: idempotencyKey,
},
telegramUserId
);
@@ -1224,8 +1411,10 @@ export async function executeServerOrder(
if (result.error) {
provisionLogger.error('서버 주문 실패', new Error(result.error), { orderData });
- // Check for specific error types
- if (result.error.includes('INSUFFICIENT_BALANCE') || result.error.includes('잔액')) {
+ // Check for specific error types (error_code first, then fallback to text matching)
+ if (result.error_code === 'INSUFFICIENT_BALANCE' ||
+ result.error.includes('INSUFFICIENT_BALANCE') ||
+ result.error.includes('잔액')) {
return {
success: false,
message: `💰 잔액이 부족합니다.\n\n입금 후 다시 시도해주세요.\n\n📌 입금 계좌\n하나은행 427-910018-27104\n(주)아이언클래드`,
@@ -1287,6 +1476,7 @@ export async function executeManageServer(
message?: string;
pricing_id?: number;
order_id?: number;
+ new_label?: string;
image?: string;
},
env?: Env,
diff --git a/src/types.ts b/src/types.ts
index 344cda9..afd936c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -452,21 +452,7 @@ export interface DomainRegisterKeyboardData {
price: number;
}
-export interface ServerOrderKeyboardData {
- type: "server_order";
- userId: string;
- index: number; // recommendations 배열 인덱스
- plan: string; // 플랜 이름
-}
-
-export interface ServerDeleteKeyboardData {
- type: "server_delete";
- orderId: number;
- label: string;
- userId: string;
-}
-
-export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData | ServerDeleteKeyboardData;
+export type KeyboardData = DomainRegisterKeyboardData;
// Bandwidth Info (shared by server-agent and server-tool)
export interface BandwidthInfo {
@@ -592,7 +578,7 @@ export interface WorkersAITextGenerationOutput {
export interface ProvisionOrder {
id: number;
user_id: number;
- status: 'provisioning' | 'active' | 'stopped' | 'deleted' | 'failed';
+ status: 'pending' | 'provisioning' | 'active' | 'stopped' | 'deleted' | 'terminated' | 'failed';
price_paid: number;
label: string;
ip_address?: string;
@@ -616,6 +602,7 @@ export interface ProvisionResponse {
success: boolean;
message?: string;
error?: string;
+ error_code?: string; // Error code: 'INSUFFICIENT_BALANCE', 'ORDER_EXISTS', 'INVALID_PRICING', etc.
order?: ProvisionOrder;
orders?: ProvisionOrder[];
images?: OSImage[];
diff --git a/src/utils/patterns.ts b/src/utils/patterns.ts
index ddd42ee..22b4132 100644
--- a/src/utils/patterns.ts
+++ b/src/utils/patterns.ts
@@ -13,7 +13,7 @@
export const DOMAIN_PATTERNS = /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i;
export const DEPOSIT_PATTERNS = /입금|충전|잔액|계좌|예치금|송금|돈/i;
-export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i;
+export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr|\d+번\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|#\d+\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|reboot/i;
export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i;
export const WEATHER_PATTERNS = /날씨|기온|비|눈|맑|흐림|더워|추워/i;
export const SEARCH_PATTERNS = /검색|찾아|뭐야|뉴스|최신/i;