feat: improve server management and refund display
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 <noreply@anthropic.com>
This commit is contained in:
63
CLAUDE.md
63
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
|
||||
|
||||
321
docs/CONSTANTS_MIGRATION.md
Normal file
321
docs/CONSTANTS_MIGRATION.md
Normal file
@@ -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
|
||||
62
migrations/005_add_stopped_status.sql
Normal file
62
migrations/005_add_stopped_status.sql
Normal file
@@ -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;
|
||||
67
migrations/005_rollback_stopped_status.sql
Normal file
67
migrations/005_rollback_stopped_status.sql
Normal file
@@ -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;
|
||||
214
src/commands.ts
214
src/commands.ts
@@ -15,40 +15,66 @@ export async function handleCommand(
|
||||
|
||||
switch (command) {
|
||||
case '/start':
|
||||
return `👋 안녕하세요! AI 어시스턴트입니다.
|
||||
return `👋 <b>AnvilHosting 고객센터</b>입니다!
|
||||
|
||||
대화를 나눌수록 당신을 더 잘 이해합니다 💡
|
||||
<b>제공 서비스:</b>
|
||||
• 🌐 도메인 등록/관리
|
||||
• 🖥️ 클라우드 서버 (서울/도쿄/오사카/싱가폴)
|
||||
• 🛡️ DDoS 방어
|
||||
• 🔐 PhantomX VPN (Xray 기반 차세대 보안)
|
||||
|
||||
<b>명령어:</b>
|
||||
/profile - 내 프로필 보기
|
||||
/help - 도움말
|
||||
/deposit - 예치금 잔액
|
||||
/domain - 내 도메인 목록
|
||||
/server - 내 서버 목록
|
||||
/security - DDoS 방어 현황
|
||||
/phantomx - PhantomX VPN
|
||||
|
||||
💡 중요한 정보는 "기억해줘"로 저장하세요!`;
|
||||
무엇을 도와드릴까요?`;
|
||||
|
||||
case '/help':
|
||||
return `📖 <b>도움말</b>
|
||||
|
||||
/profile - 내 프로필 보기
|
||||
<b>명령어:</b>
|
||||
/deposit - 예치금 잔액
|
||||
/domain - 내 도메인 목록
|
||||
/server - 내 서버 목록
|
||||
/security - DDoS 방어 서비스
|
||||
/phantomx - PhantomX VPN 서비스
|
||||
|
||||
<b>기억 기능:</b>
|
||||
• "OOO 기억해줘" - 정보 저장
|
||||
• "내 기억 보여줘" - 저장 목록
|
||||
• "OOO 잊어줘" - 삭제
|
||||
<b>자연어로 요청:</b>
|
||||
• "도메인 등록" - 도메인 검색/등록
|
||||
• "서버 추천" - 맞춤 서버 추천
|
||||
|
||||
대화할수록 당신을 더 잘 이해합니다 💡`;
|
||||
궁금한 점은 편하게 물어보세요!`;
|
||||
|
||||
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 `💰 <b>예치금 잔액</b>
|
||||
|
||||
현재 잔액: <b>${balance.toLocaleString()}원</b>
|
||||
|
||||
<b>입금 계좌:</b>
|
||||
하나은행 427-910018-27104
|
||||
예금주: (주)아이언클래드
|
||||
|
||||
입금 후 "홍길동 10000원 입금" 형식으로 알려주세요.`;
|
||||
}
|
||||
|
||||
case '/context': {
|
||||
const ctx = await getConversationContext(env.DB, userId, chatId);
|
||||
const remaining = config.threshold - ctx.recentMessages.length;
|
||||
|
||||
return `📊 <b>현재 컨텍스트</b>
|
||||
|
||||
분석된 메시지: ${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 `🌐 <b>내 도메인</b>
|
||||
|
||||
등록된 도메인이 없습니다.
|
||||
|
||||
"도메인 등록" 또는 "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 `🌐 <b>내 도메인</b> (${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 `🖥️ <b>내 서버</b>
|
||||
|
||||
보유한 서버가 없습니다.
|
||||
|
||||
"서버 추천" 또는 "서버 신청"으로 시작하세요!`;
|
||||
}
|
||||
|
||||
const statusIcon: Record<string, string> = {
|
||||
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} <b>${label}</b> (${s.region})${specInfo}`;
|
||||
}).join('\n\n');
|
||||
|
||||
return `🖥️ <b>내 서버</b> (${servers.filter(s => ['active', 'stopped', 'provisioning'].includes(s.status)).length}개)
|
||||
|
||||
${serverList}
|
||||
|
||||
서버 관리: "N번 시작/중지" 또는 "#N 재시작"`;
|
||||
}
|
||||
|
||||
case '/security': {
|
||||
return `🛡️ <b>DDoS 방어 서비스</b>
|
||||
|
||||
<b>AnvilShield</b> - 엔터프라이즈급 DDoS 방어
|
||||
|
||||
• L3/L4 네트워크 공격 방어
|
||||
• L7 애플리케이션 공격 방어
|
||||
• 실시간 트래픽 모니터링
|
||||
• 자동 위협 탐지 및 차단
|
||||
|
||||
<b>요금제:</b>
|
||||
• Basic: 10Gbps 방어 - ₩99,000/월
|
||||
• Pro: 100Gbps 방어 - ₩299,000/월
|
||||
• Enterprise: 무제한 - 별도 문의
|
||||
|
||||
🔜 <i>서비스 준비 중입니다. 문의: @AnvilSupport</i>`;
|
||||
}
|
||||
|
||||
case '/phantomx': {
|
||||
return `🔐 <b>PhantomX VPN</b>
|
||||
|
||||
<b>Xray 기반 차세대 보안 VPN</b>
|
||||
|
||||
• 🚀 초고속 연결 (Xray-core 엔진)
|
||||
• 👻 트래픽 위장 (탐지 우회)
|
||||
• 🌍 글로벌 서버 (한국/일본/미국/유럽)
|
||||
• 📱 멀티 디바이스 지원
|
||||
• 🔒 제로 로그 정책
|
||||
|
||||
<b>요금제:</b>
|
||||
• 월간: ₩9,900/월
|
||||
• 연간: ₩79,000/년 (33% 할인)
|
||||
|
||||
🔜 <i>서비스 준비 중입니다. 문의: @AnvilSupport</i>`;
|
||||
}
|
||||
|
||||
case '/debug': {
|
||||
// Admin only - exposes internal debug info
|
||||
const adminId = env.DEPOSIT_ADMIN_ID ? parseInt(env.DEPOSIT_ADMIN_ID, 10) : null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
123
src/index.ts
123
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<void> {
|
||||
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<void> {
|
||||
|
||||
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),
|
||||
`❌ <b>서버 주문 자동 취소</b>\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),
|
||||
`⏰ <b>서버 주문 자동 취소</b>\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);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,60 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
|
||||
// 사용자 조회/생성
|
||||
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<Response> {
|
||||
// 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<Response> {
|
||||
// 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;
|
||||
|
||||
@@ -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,
|
||||
`📋 <b>서버 주문 접수 완료!</b> (주문 #${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,
|
||||
`❌ <b>처리 중 오류 발생</b>
|
||||
|
||||
서버 주문 처리 중 예상치 못한 오류가 발생했습니다.
|
||||
잠시 후 다시 시도해주세요.
|
||||
|
||||
문제가 계속되면 관리자에게 문의해주세요.`
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -244,10 +244,7 @@ export async function handleMessage(
|
||||
telegramUserId
|
||||
);
|
||||
|
||||
let finalResponse = result.responseText;
|
||||
if (result.isProfileUpdated) {
|
||||
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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번호나 용도를 말씀해주세요!';
|
||||
}
|
||||
|
||||
// 선택 단계 처리
|
||||
|
||||
@@ -95,6 +95,28 @@ async function deleteServerOrder(db: D1Database, orderId: number): Promise<void>
|
||||
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<string, string> = {
|
||||
'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 });
|
||||
|
||||
@@ -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에게 전달됨 (추가 처리 불필요)
|
||||
- 기술 문제, 에러, 오류, 장애 관련 요청:
|
||||
• "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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,
|
||||
|
||||
19
src/types.ts
19
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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user