From 2b1bc6a371e8e0e185d18fcd615057083629485f Mon Sep 17 00:00:00 2001 From: kappa Date: Fri, 30 Jan 2026 05:30:59 +0900 Subject: [PATCH] feat: improve server management and refund display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server Management: - Fix /server command API auth (query param instead of header) - Show server specs (vCPU/RAM/Bandwidth) in /server list - Prevent AI from refusing server deletion based on expiration date - Add explicit instructions in tool description and system prompt Refund Display: - Show before/after balance in server deletion refund message - Format: 환불 전 잔액 → 환불 금액 → 환불 후 잔액 Other Changes: - Add stopped status migration for server orders - Clean up callback handler (remove deprecated code) - Update constants and pattern utilities Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 63 +++- docs/CONSTANTS_MIGRATION.md | 321 +++++++++++++++++++++ migrations/005_add_stopped_status.sql | 62 ++++ migrations/005_rollback_stopped_status.sql | 67 +++++ src/commands.ts | 214 +++++++++++++- src/constants/index.ts | 8 +- src/index.ts | 123 ++++++-- src/routes/api/chat.ts | 68 ++++- src/routes/handlers/callback-handler.ts | 207 ------------- src/routes/handlers/message-handler.ts | 18 +- src/server-agent.ts | 6 +- src/server-provision.ts | 159 ++++++++-- src/summary-service.ts | 5 + src/tools/index.ts | 7 +- src/tools/server-tool.ts | 252 ++++++++++++++-- src/types.ts | 19 +- src/utils/patterns.ts | 2 +- 17 files changed, 1237 insertions(+), 364 deletions(-) create mode 100644 docs/CONSTANTS_MIGRATION.md create mode 100644 migrations/005_add_stopped_status.sql create mode 100644 migrations/005_rollback_stopped_status.sql 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;