feat: add region diversity, HTML report, and transfer pricing
Region Diversity: - No region specified → same spec from 3 different regions - Cache key now includes region_preference - Fixed server_id to use ap.id (pricing) instead of ai.id (instance) HTML Report: - New /api/recommend/report endpoint for printable reports - Supports multi-language (en, ko, ja, zh) - Displays bandwidth_info with proper KRW formatting Transfer Pricing: - bandwidth_info includes overage costs from anvil_transfer_pricing - available_regions shows alternative regions with prices Code Quality: - Extracted region-utils.ts for flexible region matching - Cleaned up AI prompt (removed obsolete provider references) - Renamed project to cloud-orchestrator Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
96
CLAUDE.md
96
CLAUDE.md
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Cloudflare Worker-based AI server recommendation service. Uses OpenAI GPT-4o-mini (via AI Gateway), D1 database, KV cache, and VPS benchmark data to recommend cost-effective servers based on natural language requirements.
|
Cloudflare Worker-based AI server recommendation service. Uses OpenAI GPT-4o-mini (via AI Gateway), D1 database, KV cache, and VPS benchmark data to recommend cost-effective servers based on natural language requirements.
|
||||||
|
|
||||||
**Production URL**: `https://server-recommend.kappa-d8e.workers.dev`
|
**Production URL**: `https://cloud-orchestrator.kappa-d8e.workers.dev`
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -34,10 +34,12 @@ src/
|
|||||||
├── config.ts # Configuration constants
|
├── config.ts # Configuration constants
|
||||||
├── types.ts # TypeScript type definitions
|
├── types.ts # TypeScript type definitions
|
||||||
├── utils.ts # Utilities (bandwidth, response, AI, benchmarks, candidates, techSpecs)
|
├── utils.ts # Utilities (bandwidth, response, AI, benchmarks, candidates, techSpecs)
|
||||||
|
├── region-utils.ts # Region matching utilities (flexible region conditions)
|
||||||
└── handlers/
|
└── handlers/
|
||||||
├── health.ts # GET /api/health
|
├── health.ts # GET /api/health
|
||||||
├── servers.ts # GET /api/servers - List servers with filtering
|
├── servers.ts # GET /api/servers - List servers with filtering
|
||||||
└── recommend.ts # POST /api/recommend - AI-powered recommendations
|
├── recommend.ts # POST /api/recommend - AI-powered recommendations
|
||||||
|
└── report.ts # GET /api/recommend/report - HTML report generation
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Data Flow
|
### Key Data Flow
|
||||||
@@ -52,9 +54,10 @@ src/
|
|||||||
### D1 Database Tables (cloud-instances-db)
|
### D1 Database Tables (cloud-instances-db)
|
||||||
|
|
||||||
**Primary tables (Anvil pricing)**:
|
**Primary tables (Anvil pricing)**:
|
||||||
- `anvil_instances` - Anvil server specifications (vcpus, memory_gb, disk_gb, etc.)
|
- `anvil_instances` - Anvil server specifications (vcpus, memory_gb, disk_gb, transfer_tb, etc.)
|
||||||
- `anvil_regions` - Anvil data center regions (name, display_name, country_code)
|
- `anvil_regions` - Anvil data center regions (name, display_name, country_code)
|
||||||
- `anvil_pricing` - Anvil pricing data (monthly_price in USD)
|
- `anvil_pricing` - Anvil pricing data (monthly_price in USD)
|
||||||
|
- `anvil_transfer_pricing` - Transfer/bandwidth overage pricing by region (price_per_gb in USD)
|
||||||
|
|
||||||
**Support tables**:
|
**Support tables**:
|
||||||
- `tech_specs` - Resource requirements per technology (vcpu_per_users, min_memory_mb)
|
- `tech_specs` - Resource requirements per technology (vcpu_per_users, min_memory_mb)
|
||||||
@@ -93,7 +96,7 @@ Estimates monthly bandwidth based on use_case patterns:
|
|||||||
| Analytics | 0.7MB | 30 | 50% |
|
| Analytics | 0.7MB | 30 | 50% |
|
||||||
| Blog/Content | 1.5MB | 4 | 30% |
|
| Blog/Content | 1.5MB | 4 | 30% |
|
||||||
|
|
||||||
Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth.
|
Heavy bandwidth (>1TB/month) triggers warning about overage costs.
|
||||||
|
|
||||||
### Flexible Region Matching
|
### Flexible Region Matching
|
||||||
|
|
||||||
@@ -115,10 +118,62 @@ Country names are auto-expanded via `COUNTRY_NAME_TO_REGIONS` mapping.
|
|||||||
- Exchange rate fetched from open.er-api.com with 1-hour KV cache
|
- Exchange rate fetched from open.er-api.com with 1-hour KV cache
|
||||||
- Fallback rate: 1450 KRW/USD if API unavailable
|
- Fallback rate: 1450 KRW/USD if API unavailable
|
||||||
|
|
||||||
|
### Transfer Pricing (`bandwidth_info`)
|
||||||
|
|
||||||
|
Each recommendation includes `bandwidth_info` with transfer/bandwidth cost details:
|
||||||
|
|
||||||
|
| Field | Description | KRW Rounding |
|
||||||
|
|-------|-------------|--------------|
|
||||||
|
| `included_transfer_tb` | Free bandwidth included in plan (TB/month) | - |
|
||||||
|
| `overage_cost_per_gb` | Overage cost per GB | 1원 단위 |
|
||||||
|
| `overage_cost_per_tb` | Overage cost per TB | 100원 단위 |
|
||||||
|
| `estimated_monthly_tb` | Estimated monthly usage (TB) | - |
|
||||||
|
| `estimated_overage_tb` | Estimated overage (TB) | - |
|
||||||
|
| `estimated_overage_cost` | Estimated overage charges | 100원 단위 |
|
||||||
|
| `total_estimated_cost` | Server + overage total | 100원 단위 |
|
||||||
|
| `currency` | "USD" or "KRW" | - |
|
||||||
|
|
||||||
|
**Data sources**:
|
||||||
|
- `included_transfer_tb`: From `anvil_instances.transfer_tb`
|
||||||
|
- `overage_cost_per_gb`: From `anvil_transfer_pricing.price_per_gb`
|
||||||
|
|
||||||
|
### HTML Report Endpoint (`handlers/report.ts`)
|
||||||
|
|
||||||
|
`GET /api/recommend/report?data={base64}&lang={en|ko}`
|
||||||
|
|
||||||
|
Generates printable/PDF-friendly HTML report from recommendation results.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `data`: Base64-encoded JSON of recommendation response
|
||||||
|
- `lang`: Language (en, ko, ja, zh) - defaults to 'en'
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```javascript
|
||||||
|
// Get recommendations
|
||||||
|
const result = await fetch('/api/recommend', {...});
|
||||||
|
const data = await result.json();
|
||||||
|
|
||||||
|
// Generate report URL
|
||||||
|
const reportUrl = `/api/recommend/report?data=${btoa(JSON.stringify(data))}&lang=ko`;
|
||||||
|
window.open(reportUrl); // Opens printable HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
### Region-Based Recommendation Strategy (`recommend.ts`)
|
||||||
|
|
||||||
|
**When region IS specified** (e.g., `region_preference: ["seoul"]`):
|
||||||
|
- Returns 3 spec tiers (Budget/Balanced/Premium) within that region
|
||||||
|
- Example: Seoul 1 - Standard 4GB, Standard 8GB, Pro 16GB
|
||||||
|
|
||||||
|
**When NO region specified**:
|
||||||
|
- Returns same/similar spec from 3 DIFFERENT regions for location comparison
|
||||||
|
- Example: Standard 4GB from Osaka 2, Seoul 1, Singapore 1
|
||||||
|
- Implemented by sending only 1 server per region to AI (forces diversity)
|
||||||
|
|
||||||
### AI Prompt Strategy (`recommend.ts`)
|
### AI Prompt Strategy (`recommend.ts`)
|
||||||
|
|
||||||
- Uses OpenAI GPT-4o-mini via Cloudflare AI Gateway (bypasses regional restrictions)
|
- Uses OpenAI GPT-4o-mini via Cloudflare AI Gateway (bypasses regional restrictions)
|
||||||
- Server list format: `[server_id=XXXX] Provider Name...` for accurate ID extraction
|
- Server list format: `[server_id=XXXX] Provider Name...` for accurate ID extraction
|
||||||
|
- **server_id uses `ap.id`** (pricing ID, unique per instance+region combination)
|
||||||
- Scoring: Cost efficiency (40%) + Capacity fit (30%) + Scalability (30%)
|
- Scoring: Cost efficiency (40%) + Capacity fit (30%) + Scalability (30%)
|
||||||
- Capacity response in Korean for Korean users
|
- Capacity response in Korean for Korean users
|
||||||
- **Prompt injection protection**: User inputs sanitized via `sanitizeForAIPrompt()`
|
- **Prompt injection protection**: User inputs sanitized via `sanitizeForAIPrompt()`
|
||||||
@@ -168,24 +223,45 @@ OPENAI_API_KEY = "sk-..." # Set via wrangler secret
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Health check
|
# Health check
|
||||||
curl -s https://server-recommend.kappa-d8e.workers.dev/api/health | jq .
|
curl -s https://cloud-orchestrator.kappa-d8e.workers.dev/api/health | jq .
|
||||||
|
|
||||||
# Recommendation - nodejs/redis real-time chat (Japan)
|
# Recommendation - nodejs/redis real-time chat (Japan)
|
||||||
curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["nodejs","redis"],"expected_users":1000,"use_case":"real-time chat","region_preference":["japan"]}' | jq .
|
curl -s -X POST https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["nodejs","redis"],"expected_users":1000,"use_case":"real-time chat","region_preference":["japan"]}' | jq .
|
||||||
|
|
||||||
# Recommendation - php/mysql community forum (Korea)
|
# Recommendation - php/mysql community forum (Korea)
|
||||||
curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["php","mysql"],"expected_users":800,"use_case":"community forum","region_preference":["korea"]}' | jq .
|
curl -s -X POST https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["php","mysql"],"expected_users":800,"use_case":"community forum","region_preference":["korea"]}' | jq .
|
||||||
|
|
||||||
# Recommendation - analytics dashboard (heavier DB workload)
|
# Recommendation - analytics dashboard (heavier DB workload)
|
||||||
curl -s -X POST https://server-recommend.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["postgresql"],"expected_users":500,"use_case":"analytics dashboard","region_preference":["japan"]}' | jq .
|
curl -s -X POST https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["postgresql"],"expected_users":500,"use_case":"analytics dashboard","region_preference":["japan"]}' | jq .
|
||||||
|
|
||||||
# Server list with filters (supports flexible region: korea, seoul, tokyo, etc.)
|
# Server list with filters (supports flexible region: korea, seoul, tokyo, etc.)
|
||||||
curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea&minCpu=4" | jq .
|
curl -s "https://cloud-orchestrator.kappa-d8e.workers.dev/api/servers?region=korea&minCpu=4" | jq .
|
||||||
|
|
||||||
|
# HTML Report (encode recommendation result as base64)
|
||||||
|
# 1. Get recommendation and save to variable
|
||||||
|
RESULT=$(curl -s -X POST https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend -H "Content-Type: application/json" -d '{"tech_stack":["nodejs"],"expected_users":500,"use_case":"simple api","lang":"ko"}')
|
||||||
|
# 2. Generate report URL with base64-encoded data
|
||||||
|
REPORT_URL="https://cloud-orchestrator.kappa-d8e.workers.dev/api/recommend/report?data=$(echo $RESULT | base64 | tr -d '\n')&lang=ko"
|
||||||
|
# 3. Open in browser or fetch
|
||||||
|
echo $REPORT_URL
|
||||||
```
|
```
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
### Anvil Pricing Migration (Latest)
|
### Region Diversity & Bug Fixes (Latest)
|
||||||
|
- **Region diversity**: No region specified → same spec from 3 different regions for comparison
|
||||||
|
- **Cache key fix**: `region_preference` now included in cache key
|
||||||
|
- **Server ID fix**: Changed from `ai.id` (instance) to `ap.id` (pricing) for unique region+instance identification
|
||||||
|
- **Prompt cleanup**: Removed obsolete Linode/Vultr/DigitalOcean references (Anvil only)
|
||||||
|
|
||||||
|
### Transfer Pricing & Reporting
|
||||||
|
- **Transfer pricing**: Added `anvil_transfer_pricing` table data to recommendations
|
||||||
|
- **bandwidth_info**: Each recommendation includes transfer costs (included_tb, overage costs)
|
||||||
|
- **available_regions**: Lists other regions where same server spec is available with prices
|
||||||
|
- **HTML report**: New `/api/recommend/report` endpoint for printable reports
|
||||||
|
- **KRW conversion**: Bandwidth costs converted to KRW for Korean users (GB: 1원, TB/total: 100원 rounding)
|
||||||
|
|
||||||
|
### Anvil Pricing Migration
|
||||||
- **New tables**: Migrated from `pricing` to `anvil_pricing` tables
|
- **New tables**: Migrated from `pricing` to `anvil_pricing` tables
|
||||||
- **Provider**: Now uses "Anvil" as single provider (previously Linode/Vultr)
|
- **Provider**: Now uses "Anvil" as single provider (previously Linode/Vultr)
|
||||||
- **Exchange rate**: Real-time USD→KRW conversion via open.er-api.com
|
- **Exchange rate**: Real-time USD→KRW conversion via open.er-api.com
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "server-recommend",
|
"name": "cloud-orchestrator",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const USE_CASE_CONFIGS: UseCaseConfig[] = [
|
|||||||
export const i18n: Record<string, {
|
export const i18n: Record<string, {
|
||||||
missingFields: string;
|
missingFields: string;
|
||||||
invalidFields: string;
|
invalidFields: string;
|
||||||
|
techStackItemLength: string;
|
||||||
schema: Record<string, string>;
|
schema: Record<string, string>;
|
||||||
example: Record<string, any>;
|
example: Record<string, any>;
|
||||||
aiLanguageInstruction: string;
|
aiLanguageInstruction: string;
|
||||||
@@ -83,6 +84,7 @@ export const i18n: Record<string, {
|
|||||||
en: {
|
en: {
|
||||||
missingFields: 'Missing required fields',
|
missingFields: 'Missing required fields',
|
||||||
invalidFields: 'Invalid field values',
|
invalidFields: 'Invalid field values',
|
||||||
|
techStackItemLength: 'all items must be strings with max 50 characters',
|
||||||
schema: {
|
schema: {
|
||||||
tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']",
|
tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']",
|
||||||
expected_users: "(required) number - expected concurrent users, e.g. 1000",
|
expected_users: "(required) number - expected concurrent users, e.g. 1000",
|
||||||
@@ -102,6 +104,7 @@ export const i18n: Record<string, {
|
|||||||
zh: {
|
zh: {
|
||||||
missingFields: '缺少必填字段',
|
missingFields: '缺少必填字段',
|
||||||
invalidFields: '字段值无效',
|
invalidFields: '字段值无效',
|
||||||
|
techStackItemLength: '所有项目必须是最长50个字符的字符串',
|
||||||
schema: {
|
schema: {
|
||||||
tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']",
|
tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']",
|
||||||
expected_users: "(必填) number - 预计同时在线用户数,例如 1000",
|
expected_users: "(必填) number - 预计同时在线用户数,例如 1000",
|
||||||
@@ -121,6 +124,7 @@ export const i18n: Record<string, {
|
|||||||
ja: {
|
ja: {
|
||||||
missingFields: '必須フィールドがありません',
|
missingFields: '必須フィールドがありません',
|
||||||
invalidFields: 'フィールド値が無効です',
|
invalidFields: 'フィールド値が無効です',
|
||||||
|
techStackItemLength: 'すべての項目は最大50文字の文字列でなければなりません',
|
||||||
schema: {
|
schema: {
|
||||||
tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']",
|
tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']",
|
||||||
expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000",
|
expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000",
|
||||||
@@ -140,6 +144,7 @@ export const i18n: Record<string, {
|
|||||||
ko: {
|
ko: {
|
||||||
missingFields: '필수 필드가 누락되었습니다',
|
missingFields: '필수 필드가 누락되었습니다',
|
||||||
invalidFields: '필드 값이 잘못되었습니다',
|
invalidFields: '필드 값이 잘못되었습니다',
|
||||||
|
techStackItemLength: '모든 항목은 50자 이하의 문자열이어야 합니다',
|
||||||
schema: {
|
schema: {
|
||||||
tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']",
|
tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']",
|
||||||
expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000",
|
expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
generateCacheKey,
|
generateCacheKey,
|
||||||
estimateBandwidth,
|
estimateBandwidth,
|
||||||
calculateBandwidthInfo,
|
calculateBandwidthInfo,
|
||||||
escapeLikePattern,
|
|
||||||
isValidServer,
|
isValidServer,
|
||||||
isValidBenchmarkData,
|
isValidBenchmarkData,
|
||||||
isValidVPSBenchmark,
|
isValidVPSBenchmark,
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
sanitizeForAIPrompt,
|
sanitizeForAIPrompt,
|
||||||
getExchangeRate
|
getExchangeRate
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
import { escapeLikePattern, buildFlexibleRegionConditionsAnvil } from '../region-utils';
|
||||||
|
|
||||||
export async function handleRecommend(
|
export async function handleRecommend(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -62,7 +62,26 @@ export async function handleRecommend(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = JSON.parse(bodyText) as RecommendRequest;
|
// Parse JSON with explicit error handling
|
||||||
|
let body: RecommendRequest;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(bodyText) as RecommendRequest;
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('[Recommend] JSON parse error:', parseError instanceof Error ? parseError.message : 'Unknown');
|
||||||
|
return jsonResponse({
|
||||||
|
error: 'Invalid JSON format',
|
||||||
|
request_id: requestId,
|
||||||
|
}, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate body is an object before proceeding
|
||||||
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
|
return jsonResponse({
|
||||||
|
error: body && 'lang' in body && body.lang === 'ko' ? '요청 본문은 객체여야 합니다' : 'Request body must be an object',
|
||||||
|
request_id: requestId,
|
||||||
|
}, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
const lang = body.lang || 'en';
|
const lang = body.lang || 'en';
|
||||||
const validationError = validateRecommendRequest(body, lang);
|
const validationError = validateRecommendRequest(body, lang);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@@ -74,7 +93,6 @@ export async function handleRecommend(
|
|||||||
expected_users: body.expected_users,
|
expected_users: body.expected_users,
|
||||||
use_case_length: body.use_case.length,
|
use_case_length: body.use_case.length,
|
||||||
traffic_pattern: body.traffic_pattern,
|
traffic_pattern: body.traffic_pattern,
|
||||||
has_region_pref: !!body.region_preference,
|
|
||||||
has_budget: !!body.budget_limit,
|
has_budget: !!body.budget_limit,
|
||||||
lang: lang,
|
lang: lang,
|
||||||
});
|
});
|
||||||
@@ -227,16 +245,27 @@ export async function handleRecommend(
|
|||||||
const estimatedMemory = minMemoryMb ? Math.ceil(minMemoryMb / 1024) : 4;
|
const estimatedMemory = minMemoryMb ? Math.ceil(minMemoryMb / 1024) : 4;
|
||||||
const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr'];
|
const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr'];
|
||||||
|
|
||||||
// Phase 2: Query candidate servers and VPS benchmarks in parallel
|
// Phase 2: Parallel queries including exchange rate for Korean users
|
||||||
const [candidates, vpsBenchmarks] = await Promise.all([
|
const exchangeRatePromise = lang === 'ko' ? getExchangeRate(env) : Promise.resolve(1);
|
||||||
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang),
|
|
||||||
|
const [candidates, vpsBenchmarks, exchangeRate] = await Promise.all([
|
||||||
|
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang, 1), // Pass temporary rate of 1
|
||||||
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
|
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.warn('[Recommend] VPS benchmarks unavailable:', message);
|
console.warn('[Recommend] VPS benchmarks unavailable:', message);
|
||||||
return [] as VPSBenchmark[];
|
return [] as VPSBenchmark[];
|
||||||
}),
|
}),
|
||||||
|
exchangeRatePromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Apply exchange rate to candidates if needed (Korean users)
|
||||||
|
if (lang === 'ko' && exchangeRate !== 1) {
|
||||||
|
candidates.forEach(c => {
|
||||||
|
c.monthly_price = Math.round(c.monthly_price * exchangeRate);
|
||||||
|
c.currency = 'KRW';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Recommend] Candidate servers:', candidates.length);
|
console.log('[Recommend] Candidate servers:', candidates.length);
|
||||||
console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length);
|
console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length);
|
||||||
|
|
||||||
@@ -265,7 +294,8 @@ export async function handleRecommend(
|
|||||||
vpsBenchmarks,
|
vpsBenchmarks,
|
||||||
techSpecs,
|
techSpecs,
|
||||||
bandwidthEstimate,
|
bandwidthEstimate,
|
||||||
lang
|
lang,
|
||||||
|
exchangeRate
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length);
|
console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length);
|
||||||
@@ -315,17 +345,18 @@ async function queryCandidateServers(
|
|||||||
minMemoryMb?: number,
|
minMemoryMb?: number,
|
||||||
minVcpu?: number,
|
minVcpu?: number,
|
||||||
bandwidthEstimate?: BandwidthEstimate,
|
bandwidthEstimate?: BandwidthEstimate,
|
||||||
lang: string = 'en'
|
lang: string = 'en',
|
||||||
|
exchangeRate: number = 1
|
||||||
): Promise<Server[]> {
|
): Promise<Server[]> {
|
||||||
// Get exchange rate for KRW display (Korean users)
|
// Currency display based on language (exchange rate applied in handleRecommend)
|
||||||
const exchangeRate = lang === 'ko' ? await getExchangeRate(env) : 1;
|
const currency = 'USD'; // Always return USD prices, converted to KRW in handleRecommend if needed
|
||||||
const currency = lang === 'ko' ? 'KRW' : 'USD';
|
|
||||||
|
|
||||||
// Build query using anvil_* tables
|
// Build query using anvil_* tables
|
||||||
// anvil_pricing.monthly_price is stored in USD
|
// anvil_pricing.monthly_price is stored in USD
|
||||||
|
// Join anvil_transfer_pricing for overage bandwidth costs
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
ai.id,
|
ap.id,
|
||||||
'Anvil' as provider_name,
|
'Anvil' as provider_name,
|
||||||
ai.name as instance_id,
|
ai.name as instance_id,
|
||||||
ai.display_name as instance_name,
|
ai.display_name as instance_name,
|
||||||
@@ -340,20 +371,22 @@ async function queryCandidateServers(
|
|||||||
ap.monthly_price as monthly_price_usd,
|
ap.monthly_price as monthly_price_usd,
|
||||||
ar.display_name as region_name,
|
ar.display_name as region_name,
|
||||||
ar.name as region_code,
|
ar.name as region_code,
|
||||||
ar.country_code
|
ar.country_code,
|
||||||
|
ai.transfer_tb,
|
||||||
|
atp.price_per_gb as transfer_price_per_gb
|
||||||
FROM anvil_instances ai
|
FROM anvil_instances ai
|
||||||
JOIN anvil_pricing ap ON ap.anvil_instance_id = ai.id
|
JOIN anvil_pricing ap ON ap.anvil_instance_id = ai.id
|
||||||
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||||
|
LEFT JOIN anvil_transfer_pricing atp ON atp.anvil_region_id = ar.id
|
||||||
WHERE ai.active = 1 AND ar.active = 1
|
WHERE ai.active = 1 AND ar.active = 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: (string | number)[] = [];
|
const params: (string | number)[] = [];
|
||||||
|
|
||||||
// Filter by budget limit (convert to USD for comparison)
|
// Filter by budget limit (assume budget is in USD, conversion happens in handleRecommend)
|
||||||
if (req.budget_limit) {
|
if (req.budget_limit) {
|
||||||
const budgetUsd = lang === 'ko' ? req.budget_limit / exchangeRate : req.budget_limit;
|
|
||||||
query += ` AND ap.monthly_price <= ?`;
|
query += ` AND ap.monthly_price <= ?`;
|
||||||
params.push(budgetUsd);
|
params.push(req.budget_limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by minimum memory requirement (from tech specs)
|
// Filter by minimum memory requirement (from tech specs)
|
||||||
@@ -372,19 +405,18 @@ async function queryCandidateServers(
|
|||||||
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
|
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flexible region matching using anvil_regions table
|
// Filter by region preference if specified
|
||||||
// r.* aliases need to change to ar.* for anvil_regions
|
|
||||||
if (req.region_preference && req.region_preference.length > 0) {
|
if (req.region_preference && req.region_preference.length > 0) {
|
||||||
const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil(req.region_preference);
|
const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil(req.region_preference);
|
||||||
query += ` AND (${conditions.join(' OR ')})`;
|
if (conditions.length > 0) {
|
||||||
params.push(...regionParams);
|
query += ` AND (${conditions.join(' OR ')})`;
|
||||||
} else {
|
params.push(...regionParams);
|
||||||
// No region specified → default to Seoul/Tokyo/Singapore
|
console.log(`[Candidates] Filtering by regions: ${req.region_preference.join(', ')}`);
|
||||||
query += ` AND ${DEFAULT_ANVIL_REGION_FILTER_SQL}`;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order by price
|
// Order by price - return ALL matching servers across all regions
|
||||||
query += ` ORDER BY ap.monthly_price ASC LIMIT 50`;
|
query += ` ORDER BY ap.monthly_price ASC`;
|
||||||
|
|
||||||
const result = await db.prepare(query).bind(...params).all();
|
const result = await db.prepare(query).bind(...params).all();
|
||||||
|
|
||||||
@@ -392,17 +424,17 @@ async function queryCandidateServers(
|
|||||||
throw new Error('Failed to query candidate servers');
|
throw new Error('Failed to query candidate servers');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert USD prices to display currency and validate
|
// Add USD currency to each result and validate
|
||||||
|
// Price conversion to KRW happens in handleRecommend if needed
|
||||||
const serversWithCurrency = (result.results as unknown[]).map(server => {
|
const serversWithCurrency = (result.results as unknown[]).map(server => {
|
||||||
if (typeof server === 'object' && server !== null) {
|
if (typeof server === 'object' && server !== null) {
|
||||||
const s = server as Record<string, unknown>;
|
const s = server as Record<string, unknown>;
|
||||||
const priceUsd = s.monthly_price_usd as number;
|
|
||||||
// Convert to KRW if Korean, otherwise keep USD
|
|
||||||
const displayPrice = lang === 'ko' ? Math.round(priceUsd * exchangeRate) : priceUsd;
|
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
monthly_price: displayPrice,
|
monthly_price: s.monthly_price_usd as number,
|
||||||
currency
|
currency,
|
||||||
|
transfer_tb: s.transfer_tb as number | null,
|
||||||
|
transfer_price_per_gb: s.transfer_price_per_gb as number | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return server;
|
return server;
|
||||||
@@ -413,83 +445,11 @@ async function queryCandidateServers(
|
|||||||
if (invalidCount > 0) {
|
if (invalidCount > 0) {
|
||||||
console.warn(`[Candidates] Filtered out ${invalidCount} invalid server records`);
|
console.warn(`[Candidates] Filtered out ${invalidCount} invalid server records`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[Candidates] Found ${validServers.length} servers matching technical requirements (all regions)`);
|
||||||
return validServers;
|
return validServers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Default region filter SQL for anvil_regions (when no region is specified)
|
|
||||||
*/
|
|
||||||
const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
|
|
||||||
-- Korea (Seoul)
|
|
||||||
ar.name IN ('icn', 'ap-northeast-2') OR
|
|
||||||
LOWER(ar.display_name) LIKE '%seoul%' OR
|
|
||||||
-- Japan (Tokyo, Osaka)
|
|
||||||
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
|
||||||
LOWER(ar.name) LIKE '%tyo%' OR
|
|
||||||
LOWER(ar.name) LIKE '%osa%' OR
|
|
||||||
LOWER(ar.display_name) LIKE '%tokyo%' OR
|
|
||||||
LOWER(ar.display_name) LIKE '%osaka%' OR
|
|
||||||
-- Singapore
|
|
||||||
ar.name IN ('sgp', 'ap-southeast-1') OR
|
|
||||||
LOWER(ar.name) LIKE '%sin%' OR
|
|
||||||
LOWER(ar.name) LIKE '%sgp%' OR
|
|
||||||
LOWER(ar.display_name) LIKE '%singapore%'
|
|
||||||
)`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build flexible region matching SQL conditions for anvil_regions
|
|
||||||
*/
|
|
||||||
function buildFlexibleRegionConditionsAnvil(
|
|
||||||
regions: string[]
|
|
||||||
): { conditions: string[]; params: (string | number)[] } {
|
|
||||||
const conditions: string[] = [];
|
|
||||||
const params: (string | number)[] = [];
|
|
||||||
|
|
||||||
// Country name to region mapping
|
|
||||||
const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
|
|
||||||
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
|
||||||
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
|
||||||
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
|
||||||
'singapore': ['singapore', 'ap-southeast-1'],
|
|
||||||
'indonesia': ['jakarta', 'ap-southeast-3'],
|
|
||||||
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
|
|
||||||
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
|
|
||||||
'usa': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'us': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'united states': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'uk': ['london', 'uk-south', 'eu-west-2'],
|
|
||||||
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
|
|
||||||
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
|
|
||||||
'france': ['paris', 'eu-west-3'],
|
|
||||||
'netherlands': ['amsterdam', 'eu-west-1'],
|
|
||||||
'brazil': ['sao paulo', 'sa-east'],
|
|
||||||
'canada': ['toronto', 'montreal', 'ca-central'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const escapeLikePattern = (pattern: string): string => {
|
|
||||||
return pattern.replace(/[%_\\]/g, '\\$&');
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const region of regions) {
|
|
||||||
const lowerRegion = region.toLowerCase();
|
|
||||||
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
|
||||||
const allRegions = [lowerRegion, ...expandedRegions];
|
|
||||||
|
|
||||||
for (const r of allRegions) {
|
|
||||||
const escapedRegion = escapeLikePattern(r);
|
|
||||||
conditions.push(`(
|
|
||||||
LOWER(ar.name) = ? OR
|
|
||||||
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
|
|
||||||
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
|
|
||||||
LOWER(ar.country_code) = ?
|
|
||||||
)`);
|
|
||||||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { conditions, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query relevant benchmark data for tech stack
|
* Query relevant benchmark data for tech stack
|
||||||
*/
|
*/
|
||||||
@@ -832,7 +792,8 @@ async function getAIRecommendations(
|
|||||||
vpsBenchmarks: VPSBenchmark[],
|
vpsBenchmarks: VPSBenchmark[],
|
||||||
techSpecs: TechSpec[],
|
techSpecs: TechSpec[],
|
||||||
bandwidthEstimate: BandwidthEstimate,
|
bandwidthEstimate: BandwidthEstimate,
|
||||||
lang: string = 'en'
|
lang: string = 'en',
|
||||||
|
exchangeRate: number = 1
|
||||||
): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> {
|
): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> {
|
||||||
// Validate API key before making any API calls
|
// Validate API key before making any API calls
|
||||||
if (!apiKey || !apiKey.trim()) {
|
if (!apiKey || !apiKey.trim()) {
|
||||||
@@ -862,15 +823,9 @@ CRITICAL RULES:
|
|||||||
4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec.
|
4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec.
|
||||||
5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready).
|
5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready).
|
||||||
|
|
||||||
BANDWIDTH CONSIDERATIONS (VERY IMPORTANT):
|
BANDWIDTH CONSIDERATIONS:
|
||||||
- Estimated monthly bandwidth is provided based on concurrent users and use case.
|
- Estimated monthly bandwidth is provided based on concurrent users and use case.
|
||||||
- TOTAL COST = Base server price + Bandwidth overage charges
|
- TOTAL COST = Base server price + Bandwidth overage charges
|
||||||
- Provider bandwidth allowances:
|
|
||||||
* Linode: 1TB (1GB plan) to 20TB (192GB plan) included free, $0.005/GB overage
|
|
||||||
* Vultr: 1TB-10TB depending on plan, $0.01/GB overage (2x Linode rate)
|
|
||||||
* DigitalOcean: 1TB-12TB depending on plan, $0.01/GB overage
|
|
||||||
- For bandwidth >1TB/month: Linode is often cheaper despite higher base price
|
|
||||||
- For bandwidth >3TB/month: Linode is STRONGLY preferred (overage savings significant)
|
|
||||||
- Always mention bandwidth implications in cost_efficiency analysis
|
- Always mention bandwidth implications in cost_efficiency analysis
|
||||||
|
|
||||||
${techSpecsPrompt}
|
${techSpecsPrompt}
|
||||||
@@ -890,10 +845,35 @@ ${languageInstruction}`;
|
|||||||
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
|
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
|
||||||
|
|
||||||
// Pre-filter candidates to reduce AI prompt size and cost
|
// Pre-filter candidates to reduce AI prompt size and cost
|
||||||
// Sort by price and limit to top 15 most affordable options
|
// Ensure region diversity when no region_preference is specified
|
||||||
const topCandidates = candidates
|
let topCandidates: Server[];
|
||||||
.sort((a, b) => a.monthly_price - b.monthly_price)
|
const hasRegionPreference = req.region_preference && req.region_preference.length > 0;
|
||||||
.slice(0, 15);
|
|
||||||
|
if (hasRegionPreference) {
|
||||||
|
// If region preference specified, just take top 15 cheapest
|
||||||
|
topCandidates = candidates
|
||||||
|
.sort((a, b) => a.monthly_price - b.monthly_price)
|
||||||
|
.slice(0, 15);
|
||||||
|
} else {
|
||||||
|
// No region preference: pick ONLY the best server from EACH region
|
||||||
|
// This forces AI to recommend different regions (no choice!)
|
||||||
|
const bestByRegion = new Map<string, Server>();
|
||||||
|
for (const server of candidates) {
|
||||||
|
const region = server.region_name;
|
||||||
|
const existing = bestByRegion.get(region);
|
||||||
|
// Keep the cheapest server that meets requirements for each region
|
||||||
|
if (!existing || server.monthly_price < existing.monthly_price) {
|
||||||
|
bestByRegion.set(region, server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array and sort by price
|
||||||
|
topCandidates = Array.from(bestByRegion.values())
|
||||||
|
.sort((a, b) => a.monthly_price - b.monthly_price);
|
||||||
|
|
||||||
|
console.log(`[AI] Region diversity FORCED: ${topCandidates.length} regions, 1 server each`);
|
||||||
|
console.log(`[AI] Regions: ${topCandidates.map(s => s.region_name).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`);
|
console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`);
|
||||||
|
|
||||||
@@ -910,8 +890,7 @@ ${languageInstruction}`;
|
|||||||
- Use Case: ${sanitizedUseCase}
|
- Use Case: ${sanitizedUseCase}
|
||||||
- Traffic Pattern: ${req.traffic_pattern || 'steady'}
|
- Traffic Pattern: ${req.traffic_pattern || 'steady'}
|
||||||
- **Estimated Monthly Bandwidth**: ${bandwidthEstimate.monthly_tb >= 1 ? `${bandwidthEstimate.monthly_tb} TB` : `${bandwidthEstimate.monthly_gb} GB`} (${bandwidthEstimate.category})
|
- **Estimated Monthly Bandwidth**: ${bandwidthEstimate.monthly_tb >= 1 ? `${bandwidthEstimate.monthly_tb} TB` : `${bandwidthEstimate.monthly_gb} GB`} (${bandwidthEstimate.category})
|
||||||
${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): MUST recommend Linode over Vultr. Linode includes 1-6TB/month transfer vs Vultr overage charges ($0.01/GB). Bandwidth cost savings > base price difference.` : ''}
|
${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): Consider bandwidth overage costs when evaluating total cost.` : ''}
|
||||||
${req.region_preference ? `- Region Preference: ${req.region_preference.join(', ')}` : ''}
|
|
||||||
${req.budget_limit ? `- Budget Limit: $${req.budget_limit}/month` : ''}
|
${req.budget_limit ? `- Budget Limit: $${req.budget_limit}/month` : ''}
|
||||||
|
|
||||||
## Real VPS Benchmark Data (Geekbench 6 normalized - actual VPS tests)
|
## Real VPS Benchmark Data (Geekbench 6 normalized - actual VPS tests)
|
||||||
@@ -921,13 +900,9 @@ ${vpsBenchmarkSummary || 'No similar VPS benchmark data available.'}
|
|||||||
${benchmarkSummary || 'No relevant CPU benchmark data available.'}
|
${benchmarkSummary || 'No relevant CPU benchmark data available.'}
|
||||||
|
|
||||||
## Available Servers (IMPORTANT: Use the server_id value, NOT the list number!)
|
## Available Servers (IMPORTANT: Use the server_id value, NOT the list number!)
|
||||||
${topCandidates.map((s) => `
|
${topCandidates.map((s) => `[server_id=${s.id}] ${s.provider_name} - ${s.instance_name}
|
||||||
[server_id=${s.id}] ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''}
|
vCPU: ${s.vcpu} | RAM: ${s.memory_gb}GB | Storage: ${s.storage_gb}GB${s.gpu_count > 0 ? ` | GPU: ${s.gpu_count}x ${s.gpu_type}` : ''}
|
||||||
Instance: ${s.instance_id}
|
Price: ${s.currency === 'KRW' ? '₩' : '$'}${s.currency === 'KRW' ? Math.round(s.monthly_price).toLocaleString() : s.monthly_price.toFixed(2)}/mo | Region: ${s.region_name}`).join('\n')}
|
||||||
vCPU: ${s.vcpu} | Memory: ${s.memory_gb} GB | Storage: ${s.storage_gb} GB
|
|
||||||
Network: ${s.network_speed_gbps ? `${s.network_speed_gbps} Gbps` : 'N/A'}${s.gpu_count > 0 ? ` | GPU: ${s.gpu_count}x ${s.gpu_type || 'Unknown'}` : ' | GPU: None'}
|
|
||||||
Price: ${s.currency === 'KRW' ? '₩' : '$'}${s.currency === 'KRW' ? Math.round(s.monthly_price).toLocaleString() : s.monthly_price.toFixed(2)}/month (${s.currency}) | Region: ${s.region_name} (${s.region_code})
|
|
||||||
`).join('\n')}
|
|
||||||
|
|
||||||
Return ONLY a valid JSON object (no markdown, no code blocks) with this exact structure:
|
Return ONLY a valid JSON object (no markdown, no code blocks) with this exact structure:
|
||||||
{
|
{
|
||||||
@@ -954,7 +929,7 @@ Return ONLY a valid JSON object (no markdown, no code blocks) with this exact st
|
|||||||
}
|
}
|
||||||
|
|
||||||
Provide exactly 3 recommendations:
|
Provide exactly 3 recommendations:
|
||||||
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load (highest score if viable)
|
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load
|
||||||
2. BALANCED option: Some headroom for traffic spikes
|
2. BALANCED option: Some headroom for traffic spikes
|
||||||
3. PREMIUM option: Ready for 2-3x growth
|
3. PREMIUM option: Ready for 2-3x growth
|
||||||
|
|
||||||
@@ -1054,7 +1029,6 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have
|
|||||||
const response = openaiResult.choices[0]?.message?.content || '';
|
const response = openaiResult.choices[0]?.message?.content || '';
|
||||||
|
|
||||||
console.log('[AI] Response received from OpenAI, length:', response.length);
|
console.log('[AI] Response received from OpenAI, length:', response.length);
|
||||||
console.log('[AI] Raw response preview:', response.substring(0, 500));
|
|
||||||
|
|
||||||
// Parse AI response
|
// Parse AI response
|
||||||
const aiResult = parseAIResponse(response);
|
const aiResult = parseAIResponse(response);
|
||||||
@@ -1110,8 +1084,8 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate bandwidth info for this server
|
// Calculate bandwidth info for this server (with currency conversion for Korean)
|
||||||
const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate);
|
const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate, lang, exchangeRate);
|
||||||
|
|
||||||
// Find all available regions for the same server spec
|
// Find all available regions for the same server spec
|
||||||
const availableRegions: AvailableRegion[] = candidates
|
const availableRegions: AvailableRegion[] = candidates
|
||||||
@@ -1233,7 +1207,7 @@ function parseAIResponse(response: unknown): AIRecommendationResponse {
|
|||||||
} as AIRecommendationResponse;
|
} as AIRecommendationResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AI] Parse error:', error);
|
console.error('[AI] Parse error:', error);
|
||||||
console.error('[AI] Response was:', response);
|
console.error('[AI] Response parse failed, length:', typeof response === 'string' ? response.length : 'N/A', 'preview:', typeof response === 'string' ? response.substring(0, 100).replace(/[^\x20-\x7E]/g, '?') : 'Invalid response type');
|
||||||
throw new Error(`Failed to parse AI response: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error(`Failed to parse AI response: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
639
src/handlers/report.ts
Normal file
639
src/handlers/report.ts
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/recommend/report - Generate HTML report from recommendation results
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - data: Base64-encoded JSON of recommendation response
|
||||||
|
* - lang: Language (en, ko, ja, zh) - default: en
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Env, RecommendationResult, BandwidthEstimate } from '../types';
|
||||||
|
import { jsonResponse } from '../utils';
|
||||||
|
|
||||||
|
interface ReportData {
|
||||||
|
recommendations: RecommendationResult[];
|
||||||
|
bandwidth_estimate: BandwidthEstimate & {
|
||||||
|
calculation_note?: string;
|
||||||
|
};
|
||||||
|
total_candidates: number;
|
||||||
|
request?: {
|
||||||
|
tech_stack: string[];
|
||||||
|
expected_users: number;
|
||||||
|
use_case: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n: Record<string, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
title: 'Server Recommendation Report',
|
||||||
|
subtitle: 'AI-Powered Infrastructure Analysis',
|
||||||
|
requirements: 'Requirements',
|
||||||
|
techStack: 'Tech Stack',
|
||||||
|
expectedUsers: 'Expected Users',
|
||||||
|
useCase: 'Use Case',
|
||||||
|
bandwidthEstimate: 'Bandwidth Estimate',
|
||||||
|
monthly: 'Monthly',
|
||||||
|
daily: 'Daily',
|
||||||
|
category: 'Category',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
budget: 'Budget',
|
||||||
|
balanced: 'Balanced',
|
||||||
|
premium: 'Premium',
|
||||||
|
specs: 'Specifications',
|
||||||
|
vcpu: 'vCPU',
|
||||||
|
memory: 'Memory',
|
||||||
|
storage: 'Storage',
|
||||||
|
region: 'Region',
|
||||||
|
price: 'Price',
|
||||||
|
score: 'Score',
|
||||||
|
analysis: 'Analysis',
|
||||||
|
techFit: 'Tech Fit',
|
||||||
|
capacity: 'Capacity',
|
||||||
|
costEfficiency: 'Cost Efficiency',
|
||||||
|
scalability: 'Scalability',
|
||||||
|
bandwidthInfo: 'Bandwidth Cost',
|
||||||
|
includedTransfer: 'Included Transfer',
|
||||||
|
overagePrice: 'Overage Price',
|
||||||
|
estimatedUsage: 'Estimated Usage',
|
||||||
|
estimatedOverage: 'Estimated Overage',
|
||||||
|
estimatedCost: 'Est. Overage Cost',
|
||||||
|
totalCost: 'Total Est. Cost',
|
||||||
|
warning: 'Warning',
|
||||||
|
generatedAt: 'Generated at',
|
||||||
|
poweredBy: 'Powered by Cloud Orchestrator',
|
||||||
|
printNote: 'Print this page or save as PDF using your browser',
|
||||||
|
perMonth: '/month',
|
||||||
|
perGb: '/GB',
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
title: '서버 추천 보고서',
|
||||||
|
subtitle: 'AI 기반 인프라 분석',
|
||||||
|
requirements: '요구사항',
|
||||||
|
techStack: '기술 스택',
|
||||||
|
expectedUsers: '예상 사용자',
|
||||||
|
useCase: '용도',
|
||||||
|
bandwidthEstimate: '대역폭 예측',
|
||||||
|
monthly: '월간',
|
||||||
|
daily: '일간',
|
||||||
|
category: '카테고리',
|
||||||
|
recommendations: '추천 서버',
|
||||||
|
budget: '예산형',
|
||||||
|
balanced: '균형형',
|
||||||
|
premium: '프리미엄',
|
||||||
|
specs: '사양',
|
||||||
|
vcpu: 'vCPU',
|
||||||
|
memory: '메모리',
|
||||||
|
storage: '스토리지',
|
||||||
|
region: '리전',
|
||||||
|
price: '가격',
|
||||||
|
score: '점수',
|
||||||
|
analysis: '분석',
|
||||||
|
techFit: '기술 적합성',
|
||||||
|
capacity: '용량',
|
||||||
|
costEfficiency: '비용 효율성',
|
||||||
|
scalability: '확장성',
|
||||||
|
bandwidthInfo: '대역폭 비용',
|
||||||
|
includedTransfer: '기본 제공',
|
||||||
|
overagePrice: '초과 요금',
|
||||||
|
estimatedUsage: '예상 사용량',
|
||||||
|
estimatedOverage: '예상 초과량',
|
||||||
|
estimatedCost: '예상 초과 비용',
|
||||||
|
totalCost: '총 예상 비용',
|
||||||
|
warning: '주의',
|
||||||
|
generatedAt: '생성 시각',
|
||||||
|
poweredBy: 'Cloud Orchestrator 제공',
|
||||||
|
printNote: '브라우저에서 인쇄하거나 PDF로 저장하세요',
|
||||||
|
perMonth: '/월',
|
||||||
|
perGb: '/GB',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLabels(lang: string): Record<string, string> {
|
||||||
|
return i18n[lang] || i18n['en'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number, currency: string): string {
|
||||||
|
if (currency === 'KRW') {
|
||||||
|
return `₩${Math.round(price).toLocaleString()}`;
|
||||||
|
}
|
||||||
|
return `$${price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTierLabel(index: number, labels: Record<string, string>): string {
|
||||||
|
const tiers = [labels.budget, labels.balanced, labels.premium];
|
||||||
|
return tiers[index] || `Option ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTierColor(index: number): string {
|
||||||
|
const colors = ['#10b981', '#3b82f6', '#8b5cf6']; // green, blue, purple
|
||||||
|
return colors[index] || '#6b7280';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleReport(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
corsHeaders: Record<string, string>
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const dataParam = url.searchParams.get('data');
|
||||||
|
const lang = url.searchParams.get('lang') || 'en';
|
||||||
|
|
||||||
|
if (!dataParam) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'Missing data parameter. Provide Base64-encoded recommendation data.' },
|
||||||
|
400,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode Base64 data
|
||||||
|
let reportData: ReportData;
|
||||||
|
try {
|
||||||
|
const decoded = atob(dataParam);
|
||||||
|
reportData = JSON.parse(decoded) as ReportData;
|
||||||
|
} catch {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'Invalid data parameter. Must be valid Base64-encoded JSON.' },
|
||||||
|
400,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reportData.recommendations || reportData.recommendations.length === 0) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'No recommendations in data.' },
|
||||||
|
400,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = getLabels(lang);
|
||||||
|
const html = generateReportHTML(reportData, labels, lang);
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
...corsHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Report] Error:', error);
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'Failed to generate report' },
|
||||||
|
500,
|
||||||
|
corsHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReportHTML(
|
||||||
|
data: ReportData,
|
||||||
|
labels: Record<string, string>,
|
||||||
|
lang: string
|
||||||
|
): string {
|
||||||
|
const { recommendations, bandwidth_estimate, request } = data;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const recommendationCards = recommendations.map((rec, index) => {
|
||||||
|
const { server, score, analysis, bandwidth_info } = rec;
|
||||||
|
const tierLabel = getTierLabel(index, labels);
|
||||||
|
const tierColor = getTierColor(index);
|
||||||
|
const currency = server.currency || 'USD';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="recommendation-card" style="border-left-color: ${tierColor}">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="tier-badge" style="background-color: ${tierColor}">${tierLabel}</div>
|
||||||
|
<div class="score">${labels.score}: ${score}/100</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="server-name">${server.provider_name} - ${server.instance_name}</h3>
|
||||||
|
|
||||||
|
<div class="specs-grid">
|
||||||
|
<div class="spec-item">
|
||||||
|
<span class="spec-label">${labels.vcpu}</span>
|
||||||
|
<span class="spec-value">${server.vcpu} cores</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-item">
|
||||||
|
<span class="spec-label">${labels.memory}</span>
|
||||||
|
<span class="spec-value">${server.memory_gb} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-item">
|
||||||
|
<span class="spec-label">${labels.storage}</span>
|
||||||
|
<span class="spec-value">${server.storage_gb} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-item">
|
||||||
|
<span class="spec-label">${labels.region}</span>
|
||||||
|
<span class="spec-value">${server.region_name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="spec-item highlight">
|
||||||
|
<span class="spec-label">${labels.price}</span>
|
||||||
|
<span class="spec-value">${formatPrice(server.monthly_price, currency)}${labels.perMonth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${bandwidth_info ? `
|
||||||
|
<div class="bandwidth-section">
|
||||||
|
<h4>${labels.bandwidthInfo}</h4>
|
||||||
|
<div class="bandwidth-grid">
|
||||||
|
<div class="bandwidth-item">
|
||||||
|
<span class="bw-label">${labels.includedTransfer}</span>
|
||||||
|
<span class="bw-value">${bandwidth_info.included_transfer_tb} TB${labels.perMonth}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bandwidth-item">
|
||||||
|
<span class="bw-label">${labels.overagePrice}</span>
|
||||||
|
<span class="bw-value">${bandwidth_info.currency === 'KRW' ? `₩${bandwidth_info.overage_cost_per_gb}` : `$${bandwidth_info.overage_cost_per_gb.toFixed(4)}`}${labels.perGb}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bandwidth-item">
|
||||||
|
<span class="bw-label">${labels.estimatedUsage}</span>
|
||||||
|
<span class="bw-value">${bandwidth_info.estimated_monthly_tb} TB</span>
|
||||||
|
</div>
|
||||||
|
${bandwidth_info.estimated_overage_tb > 0 ? `
|
||||||
|
<div class="bandwidth-item warning">
|
||||||
|
<span class="bw-label">${labels.estimatedOverage}</span>
|
||||||
|
<span class="bw-value">${bandwidth_info.estimated_overage_tb} TB</span>
|
||||||
|
</div>
|
||||||
|
<div class="bandwidth-item warning">
|
||||||
|
<span class="bw-label">${labels.estimatedCost}</span>
|
||||||
|
<span class="bw-value">+${bandwidth_info.currency === 'KRW' ? `₩${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}` : `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="bandwidth-item total">
|
||||||
|
<span class="bw-label">${labels.totalCost}</span>
|
||||||
|
<span class="bw-value">${bandwidth_info.currency === 'KRW' ? `₩${Math.round(bandwidth_info.total_estimated_cost).toLocaleString()}` : `$${bandwidth_info.total_estimated_cost.toFixed(2)}`}${labels.perMonth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${bandwidth_info.warning ? `<div class="bandwidth-warning">${bandwidth_info.warning}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="analysis-section">
|
||||||
|
<h4>${labels.analysis}</h4>
|
||||||
|
<div class="analysis-item">
|
||||||
|
<strong>${labels.techFit}:</strong> ${analysis.tech_fit}
|
||||||
|
</div>
|
||||||
|
<div class="analysis-item">
|
||||||
|
<strong>${labels.capacity}:</strong> ${analysis.capacity}
|
||||||
|
</div>
|
||||||
|
<div class="analysis-item">
|
||||||
|
<strong>${labels.costEfficiency}:</strong> ${analysis.cost_efficiency}
|
||||||
|
</div>
|
||||||
|
<div class="analysis-item">
|
||||||
|
<strong>${labels.scalability}:</strong> ${analysis.scalability}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="${lang}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${labels.title}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-section {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-section h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-item {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-estimate {
|
||||||
|
background: #eff6ff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-estimate h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #1e40af;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bw-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bw-stat {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bw-stat strong {
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-left: 4px solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge {
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-item {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-item.highlight {
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-value {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-section {
|
||||||
|
background: #fefce8;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-section h4 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #854d0e;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-item.warning {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-item.total {
|
||||||
|
font-weight: 600;
|
||||||
|
border-top: 1px solid #fde68a;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bw-label {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bw-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandwidth-warning {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-section h4 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-item {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-item strong {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-card {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>${labels.title}</h1>
|
||||||
|
<p class="subtitle">${labels.subtitle}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="print-note">
|
||||||
|
🖨️ ${labels.printNote}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${request ? `
|
||||||
|
<section class="requirements-section">
|
||||||
|
<h2>${labels.requirements}</h2>
|
||||||
|
<div class="req-grid">
|
||||||
|
<div class="req-item">
|
||||||
|
<span class="req-label">${labels.techStack}</span>
|
||||||
|
<span class="req-value">${request.tech_stack.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="req-item">
|
||||||
|
<span class="req-label">${labels.expectedUsers}</span>
|
||||||
|
<span class="req-value">${request.expected_users.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="req-item">
|
||||||
|
<span class="req-label">${labels.useCase}</span>
|
||||||
|
<span class="req-value">${request.use_case}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${bandwidth_estimate ? `
|
||||||
|
<div class="bandwidth-estimate">
|
||||||
|
<h3>${labels.bandwidthEstimate}</h3>
|
||||||
|
<div class="bw-stats">
|
||||||
|
<span class="bw-stat"><strong>${labels.monthly}:</strong> ${bandwidth_estimate.monthly_tb >= 1 ? `${bandwidth_estimate.monthly_tb} TB` : `${bandwidth_estimate.monthly_gb} GB`}</span>
|
||||||
|
<span class="bw-stat"><strong>${labels.daily}:</strong> ${bandwidth_estimate.daily_gb} GB</span>
|
||||||
|
<span class="bw-stat"><strong>${labels.category}:</strong> ${bandwidth_estimate.category}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</section>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="section-title">${labels.recommendations}</h2>
|
||||||
|
${recommendationCards}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>${labels.generatedAt}: ${now}</p>
|
||||||
|
<p>${labels.poweredBy}</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -4,85 +4,10 @@
|
|||||||
|
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
import { jsonResponse, isValidServer } from '../utils';
|
import { jsonResponse, isValidServer } from '../utils';
|
||||||
|
import {
|
||||||
/**
|
DEFAULT_ANVIL_REGION_FILTER_SQL,
|
||||||
* Default region filter SQL for anvil_regions (when no region is specified)
|
buildFlexibleRegionConditionsAnvil
|
||||||
*/
|
} from '../region-utils';
|
||||||
const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
|
|
||||||
-- Korea (Seoul)
|
|
||||||
ar.name IN ('icn', 'ap-northeast-2') OR
|
|
||||||
LOWER(ar.display_name) LIKE '%seoul%' OR
|
|
||||||
-- Japan (Tokyo, Osaka)
|
|
||||||
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
|
||||||
LOWER(ar.name) LIKE '%tyo%' OR
|
|
||||||
LOWER(ar.name) LIKE '%osa%' OR
|
|
||||||
LOWER(ar.display_name) LIKE '%tokyo%' OR
|
|
||||||
LOWER(ar.display_name) LIKE '%osaka%' OR
|
|
||||||
-- Singapore
|
|
||||||
ar.name IN ('sgp', 'ap-southeast-1') OR
|
|
||||||
LOWER(ar.name) LIKE '%sin%' OR
|
|
||||||
LOWER(ar.name) LIKE '%sgp%' OR
|
|
||||||
LOWER(ar.display_name) LIKE '%singapore%'
|
|
||||||
)`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Country name to region code/city mapping for flexible region matching
|
|
||||||
*/
|
|
||||||
const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
|
|
||||||
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
|
||||||
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
|
||||||
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
|
||||||
'singapore': ['singapore', 'ap-southeast-1'],
|
|
||||||
'indonesia': ['jakarta', 'ap-southeast-3'],
|
|
||||||
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
|
|
||||||
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
|
|
||||||
'usa': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'us': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'united states': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'uk': ['london', 'uk-south', 'eu-west-2'],
|
|
||||||
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
|
|
||||||
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
|
|
||||||
'france': ['paris', 'eu-west-3'],
|
|
||||||
'netherlands': ['amsterdam', 'eu-west-1'],
|
|
||||||
'brazil': ['sao paulo', 'sa-east'],
|
|
||||||
'canada': ['toronto', 'montreal', 'ca-central'],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape LIKE pattern special characters
|
|
||||||
*/
|
|
||||||
function escapeLikePattern(pattern: string): string {
|
|
||||||
return pattern.replace(/[%_\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build flexible region matching SQL conditions for anvil_regions
|
|
||||||
*/
|
|
||||||
function buildFlexibleRegionConditionsAnvil(
|
|
||||||
regions: string[]
|
|
||||||
): { conditions: string[]; params: (string | number)[] } {
|
|
||||||
const conditions: string[] = [];
|
|
||||||
const params: (string | number)[] = [];
|
|
||||||
|
|
||||||
for (const region of regions) {
|
|
||||||
const lowerRegion = region.toLowerCase();
|
|
||||||
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
|
||||||
const allRegions = [lowerRegion, ...expandedRegions];
|
|
||||||
|
|
||||||
for (const r of allRegions) {
|
|
||||||
const escapedRegion = escapeLikePattern(r);
|
|
||||||
conditions.push(`(
|
|
||||||
LOWER(ar.name) = ? OR
|
|
||||||
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
|
|
||||||
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
|
|
||||||
LOWER(ar.country_code) = ?
|
|
||||||
)`);
|
|
||||||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { conditions, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/servers - Server list with filtering
|
* GET /api/servers - Server list with filtering
|
||||||
@@ -105,6 +30,18 @@ export async function handleGetServers(
|
|||||||
region,
|
region,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate cache key from query parameters
|
||||||
|
const cacheKey = `servers:${url.search || 'all'}`;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (env.CACHE) {
|
||||||
|
const cached = await env.CACHE.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
console.log('[GetServers] Cache hit for:', cacheKey);
|
||||||
|
return jsonResponse({ ...JSON.parse(cached), cached: true }, 200, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build SQL query using anvil_* tables
|
// Build SQL query using anvil_* tables
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -182,15 +119,20 @@ export async function handleGetServers(
|
|||||||
|
|
||||||
console.log('[GetServers] Found servers:', servers.length);
|
console.log('[GetServers] Found servers:', servers.length);
|
||||||
|
|
||||||
return jsonResponse(
|
const responseData = {
|
||||||
{
|
servers,
|
||||||
servers,
|
count: servers.length,
|
||||||
count: servers.length,
|
filters: { minCpu, minMemory, region },
|
||||||
filters: { minCpu, minMemory, region },
|
};
|
||||||
},
|
|
||||||
200,
|
// Cache successful results (only if we have servers)
|
||||||
corsHeaders
|
if (env.CACHE && servers.length > 0) {
|
||||||
);
|
await env.CACHE.put(cacheKey, JSON.stringify(responseData), {
|
||||||
|
expirationTtl: 300, // 5 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(responseData, 200, corsHeaders);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GetServers] Error:', error);
|
console.error('[GetServers] Error:', error);
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getAllowedOrigin, checkRateLimit, jsonResponse } from './utils';
|
|||||||
import { handleHealth } from './handlers/health';
|
import { handleHealth } from './handlers/health';
|
||||||
import { handleGetServers } from './handlers/servers';
|
import { handleGetServers } from './handlers/servers';
|
||||||
import { handleRecommend } from './handlers/recommend';
|
import { handleRecommend } from './handlers/recommend';
|
||||||
|
import { handleReport } from './handlers/report';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main request handler
|
* Main request handler
|
||||||
@@ -66,6 +67,10 @@ export default {
|
|||||||
return handleRecommend(request, env, corsHeaders);
|
return handleRecommend(request, env, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/recommend/report' && request.method === 'GET') {
|
||||||
|
return handleReport(request, env, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{ error: 'Not found', request_id: requestId },
|
{ error: 'Not found', request_id: requestId },
|
||||||
404,
|
404,
|
||||||
|
|||||||
137
src/region-utils.ts
Normal file
137
src/region-utils.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Region-related utilities for flexible region matching
|
||||||
|
* Consolidates duplicated region code from utils.ts, recommend.ts, and servers.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Country name to region code/city mapping for flexible region matching
|
||||||
|
* Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi')
|
||||||
|
*/
|
||||||
|
export const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
|
||||||
|
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||||
|
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||||
|
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
||||||
|
'singapore': ['singapore', 'ap-southeast-1'],
|
||||||
|
'indonesia': ['jakarta', 'ap-southeast-3'],
|
||||||
|
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
|
||||||
|
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
|
||||||
|
'usa': ['us-east', 'us-west', 'us-central', 'america'],
|
||||||
|
'us': ['us-east', 'us-west', 'us-central', 'america'],
|
||||||
|
'united states': ['us-east', 'us-west', 'us-central', 'america'],
|
||||||
|
'uk': ['london', 'uk-south', 'eu-west-2'],
|
||||||
|
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
|
||||||
|
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
|
||||||
|
'france': ['paris', 'eu-west-3'],
|
||||||
|
'netherlands': ['amsterdam', 'eu-west-1'],
|
||||||
|
'brazil': ['sao paulo', 'sa-east'],
|
||||||
|
'canada': ['toronto', 'montreal', 'ca-central'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape LIKE pattern special characters
|
||||||
|
*/
|
||||||
|
export function escapeLikePattern(pattern: string): string {
|
||||||
|
return pattern.replace(/[%_\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default region filter SQL for legacy regions table (when no region is specified)
|
||||||
|
* Used in /api/recommend for backward compatibility
|
||||||
|
*/
|
||||||
|
export const DEFAULT_REGION_FILTER_SQL = `(
|
||||||
|
-- Korea (Seoul)
|
||||||
|
r.region_code IN ('icn', 'ap-northeast-2') OR
|
||||||
|
LOWER(r.region_name) LIKE '%seoul%' OR
|
||||||
|
-- Japan (Tokyo, Osaka)
|
||||||
|
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
||||||
|
LOWER(r.region_code) LIKE '%tyo%' OR
|
||||||
|
LOWER(r.region_code) LIKE '%osa%' OR
|
||||||
|
LOWER(r.region_name) LIKE '%tokyo%' OR
|
||||||
|
LOWER(r.region_name) LIKE '%osaka%' OR
|
||||||
|
-- Singapore
|
||||||
|
r.region_code IN ('sgp', 'ap-southeast-1') OR
|
||||||
|
LOWER(r.region_code) LIKE '%sin%' OR
|
||||||
|
LOWER(r.region_code) LIKE '%sgp%' OR
|
||||||
|
LOWER(r.region_name) LIKE '%singapore%'
|
||||||
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default region filter SQL for anvil_regions (when no region is specified)
|
||||||
|
* Used in both /api/recommend and /api/servers
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
|
||||||
|
-- Korea (Seoul)
|
||||||
|
ar.name IN ('icn', 'ap-northeast-2') OR
|
||||||
|
LOWER(ar.display_name) LIKE '%seoul%' OR
|
||||||
|
-- Japan (Tokyo, Osaka)
|
||||||
|
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
||||||
|
LOWER(ar.name) LIKE '%tyo%' OR
|
||||||
|
LOWER(ar.name) LIKE '%osa%' OR
|
||||||
|
LOWER(ar.display_name) LIKE '%tokyo%' OR
|
||||||
|
LOWER(ar.display_name) LIKE '%osaka%' OR
|
||||||
|
-- Singapore
|
||||||
|
ar.name IN ('sgp', 'ap-southeast-1') OR
|
||||||
|
LOWER(ar.name) LIKE '%sin%' OR
|
||||||
|
LOWER(ar.name) LIKE '%sgp%' OR
|
||||||
|
LOWER(ar.display_name) LIKE '%singapore%'
|
||||||
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build flexible region matching SQL conditions for legacy regions table
|
||||||
|
* Returns SQL conditions and parameters for use in prepared statements
|
||||||
|
*/
|
||||||
|
export function buildFlexibleRegionConditions(
|
||||||
|
regions: string[]
|
||||||
|
): { conditions: string[]; params: (string | number)[] } {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
|
||||||
|
for (const region of regions) {
|
||||||
|
const lowerRegion = region.toLowerCase();
|
||||||
|
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
||||||
|
const allRegions = [lowerRegion, ...expandedRegions];
|
||||||
|
|
||||||
|
for (const r of allRegions) {
|
||||||
|
const escapedRegion = escapeLikePattern(r);
|
||||||
|
conditions.push(`(
|
||||||
|
LOWER(r.region_code) = ? OR
|
||||||
|
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
|
||||||
|
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
|
||||||
|
LOWER(r.country_code) = ?
|
||||||
|
)`);
|
||||||
|
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conditions, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build flexible region matching SQL conditions for anvil_regions
|
||||||
|
* Returns SQL conditions and parameters for use in prepared statements
|
||||||
|
*/
|
||||||
|
export function buildFlexibleRegionConditionsAnvil(
|
||||||
|
regions: string[]
|
||||||
|
): { conditions: string[]; params: (string | number)[] } {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
|
||||||
|
for (const region of regions) {
|
||||||
|
const lowerRegion = region.toLowerCase();
|
||||||
|
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
||||||
|
const allRegions = [lowerRegion, ...expandedRegions];
|
||||||
|
|
||||||
|
for (const r of allRegions) {
|
||||||
|
const escapedRegion = escapeLikePattern(r);
|
||||||
|
conditions.push(`(
|
||||||
|
LOWER(ar.name) = ? OR
|
||||||
|
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
|
||||||
|
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
|
||||||
|
LOWER(ar.country_code) = ?
|
||||||
|
)`);
|
||||||
|
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conditions, params };
|
||||||
|
}
|
||||||
10
src/types.ts
10
src/types.ts
@@ -50,16 +50,20 @@ export interface Server {
|
|||||||
currency: 'USD' | 'KRW';
|
currency: 'USD' | 'KRW';
|
||||||
region_name: string;
|
region_name: string;
|
||||||
region_code: string;
|
region_code: string;
|
||||||
|
// Transfer pricing (from anvil_instances + anvil_transfer_pricing)
|
||||||
|
transfer_tb: number | null; // 기본 포함 트래픽 (TB/월)
|
||||||
|
transfer_price_per_gb: number | null; // 초과 트래픽 가격 ($/GB)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BandwidthInfo {
|
export interface BandwidthInfo {
|
||||||
included_transfer_tb: number; // 기본 포함 트래픽 (TB/월)
|
included_transfer_tb: number; // 기본 포함 트래픽 (TB/월)
|
||||||
overage_cost_per_gb: number; // 초과 비용 ($/GB)
|
overage_cost_per_gb: number; // 초과 비용 ($/GB 또는 ₩/GB)
|
||||||
overage_cost_per_tb: number; // 초과 비용 ($/TB)
|
overage_cost_per_tb: number; // 초과 비용 ($/TB 또는 ₩/TB)
|
||||||
estimated_monthly_tb: number; // 예상 월간 사용량 (TB)
|
estimated_monthly_tb: number; // 예상 월간 사용량 (TB)
|
||||||
estimated_overage_tb: number; // 예상 초과량 (TB)
|
estimated_overage_tb: number; // 예상 초과량 (TB)
|
||||||
estimated_overage_cost: number; // 예상 초과 비용 ($)
|
estimated_overage_cost: number; // 예상 초과 비용
|
||||||
total_estimated_cost: number; // 총 예상 비용 (서버 + 트래픽)
|
total_estimated_cost: number; // 총 예상 비용 (서버 + 트래픽)
|
||||||
|
currency: 'USD' | 'KRW'; // 통화
|
||||||
warning?: string; // 트래픽 관련 경고
|
warning?: string; // 트래픽 관련 경고
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
238
src/utils.ts
238
src/utils.ts
@@ -88,16 +88,17 @@ export function generateCacheKey(req: RecommendRequest): string {
|
|||||||
parts.push(`traffic:${req.traffic_pattern}`);
|
parts.push(`traffic:${req.traffic_pattern}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.region_preference) {
|
|
||||||
const sortedRegions = [...req.region_preference].sort();
|
|
||||||
const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(',');
|
|
||||||
parts.push(`reg:${sanitizedRegions}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.budget_limit) {
|
if (req.budget_limit) {
|
||||||
parts.push(`budget:${req.budget_limit}`);
|
parts.push(`budget:${req.budget_limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include region preference in cache key
|
||||||
|
if (req.region_preference && req.region_preference.length > 0) {
|
||||||
|
const sortedRegions = [...req.region_preference].sort();
|
||||||
|
const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(',');
|
||||||
|
parts.push(`region:${sanitizedRegions}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Include language in cache key
|
// Include language in cache key
|
||||||
if (req.lang) {
|
if (req.lang) {
|
||||||
parts.push(`lang:${req.lang}`);
|
parts.push(`lang:${req.lang}`);
|
||||||
@@ -107,85 +108,15 @@ export function generateCacheKey(req: RecommendRequest): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default region filter SQL for when no region is specified
|
* Re-export region utilities from region-utils.ts for backward compatibility
|
||||||
* Used in both /api/recommend and /api/servers
|
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_REGION_FILTER_SQL = `(
|
export {
|
||||||
-- Korea (Seoul)
|
DEFAULT_ANVIL_REGION_FILTER_SQL,
|
||||||
r.region_code IN ('icn', 'ap-northeast-2') OR
|
COUNTRY_NAME_TO_REGIONS,
|
||||||
LOWER(r.region_name) LIKE '%seoul%' OR
|
escapeLikePattern,
|
||||||
-- Japan (Tokyo, Osaka)
|
buildFlexibleRegionConditions,
|
||||||
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
buildFlexibleRegionConditionsAnvil
|
||||||
LOWER(r.region_code) LIKE '%tyo%' OR
|
} from './region-utils';
|
||||||
LOWER(r.region_code) LIKE '%osa%' OR
|
|
||||||
LOWER(r.region_name) LIKE '%tokyo%' OR
|
|
||||||
LOWER(r.region_name) LIKE '%osaka%' OR
|
|
||||||
-- Singapore
|
|
||||||
r.region_code IN ('sgp', 'ap-southeast-1') OR
|
|
||||||
LOWER(r.region_code) LIKE '%sin%' OR
|
|
||||||
LOWER(r.region_code) LIKE '%sgp%' OR
|
|
||||||
LOWER(r.region_name) LIKE '%singapore%'
|
|
||||||
)`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape LIKE pattern special characters
|
|
||||||
*/
|
|
||||||
export function escapeLikePattern(pattern: string): string {
|
|
||||||
return pattern.replace(/[%_\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Country name to region code/city mapping for flexible region matching
|
|
||||||
* Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi')
|
|
||||||
*/
|
|
||||||
export const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
|
|
||||||
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
|
||||||
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
|
||||||
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
|
||||||
'singapore': ['singapore', 'ap-southeast-1'],
|
|
||||||
'indonesia': ['jakarta', 'ap-southeast-3'],
|
|
||||||
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
|
|
||||||
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
|
|
||||||
'usa': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'us': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'united states': ['us-east', 'us-west', 'us-central', 'america'],
|
|
||||||
'uk': ['london', 'uk-south', 'eu-west-2'],
|
|
||||||
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
|
|
||||||
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
|
|
||||||
'france': ['paris', 'eu-west-3'],
|
|
||||||
'netherlands': ['amsterdam', 'eu-west-1'],
|
|
||||||
'brazil': ['sao paulo', 'sa-east'],
|
|
||||||
'canada': ['toronto', 'montreal', 'ca-central'],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build flexible region matching SQL conditions and parameters
|
|
||||||
*/
|
|
||||||
export function buildFlexibleRegionConditions(
|
|
||||||
regions: string[]
|
|
||||||
): { conditions: string[]; params: (string | number)[] } {
|
|
||||||
const conditions: string[] = [];
|
|
||||||
const params: (string | number)[] = [];
|
|
||||||
|
|
||||||
for (const region of regions) {
|
|
||||||
const lowerRegion = region.toLowerCase();
|
|
||||||
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
|
||||||
const allRegions = [lowerRegion, ...expandedRegions];
|
|
||||||
|
|
||||||
for (const r of allRegions) {
|
|
||||||
const escapedRegion = escapeLikePattern(r);
|
|
||||||
conditions.push(`(
|
|
||||||
LOWER(r.region_code) = ? OR
|
|
||||||
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
|
|
||||||
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
|
|
||||||
LOWER(r.country_code) = ?
|
|
||||||
)`);
|
|
||||||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { conditions, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard to validate Server object structure
|
* Type guard to validate Server object structure
|
||||||
@@ -288,8 +219,10 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida
|
|||||||
invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' });
|
invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' });
|
||||||
} else if (body.tech_stack.length > LIMITS.MAX_TECH_STACK) {
|
} else if (body.tech_stack.length > LIMITS.MAX_TECH_STACK) {
|
||||||
invalidFields.push({ field: 'tech_stack', reason: `must not exceed ${LIMITS.MAX_TECH_STACK} items` });
|
invalidFields.push({ field: 'tech_stack', reason: `must not exceed ${LIMITS.MAX_TECH_STACK} items` });
|
||||||
} else if (!body.tech_stack.every((item: any) => typeof item === 'string')) {
|
} else if (!body.tech_stack.every((item: unknown) =>
|
||||||
invalidFields.push({ field: 'tech_stack', reason: 'all items must be strings' });
|
typeof item === 'string' && item.length <= 50
|
||||||
|
)) {
|
||||||
|
invalidFields.push({ field: 'tech_stack', reason: messages.techStackItemLength || 'all items must be strings with max 50 characters' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.expected_users === undefined) {
|
if (body.expected_users === undefined) {
|
||||||
@@ -313,16 +246,6 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida
|
|||||||
invalidFields.push({ field: 'traffic_pattern', reason: "must be one of: 'steady', 'spiky', 'growing'" });
|
invalidFields.push({ field: 'traffic_pattern', reason: "must be one of: 'steady', 'spiky', 'growing'" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.region_preference !== undefined) {
|
|
||||||
if (!Array.isArray(body.region_preference)) {
|
|
||||||
invalidFields.push({ field: 'region_preference', reason: 'must be an array' });
|
|
||||||
} else if (body.region_preference.length > LIMITS.MAX_REGION_PREFERENCE) {
|
|
||||||
invalidFields.push({ field: 'region_preference', reason: `must not exceed ${LIMITS.MAX_REGION_PREFERENCE} items` });
|
|
||||||
} else if (!body.region_preference.every((item: any) => typeof item === 'string')) {
|
|
||||||
invalidFields.push({ field: 'region_preference', reason: 'all items must be strings' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.budget_limit !== undefined && (typeof body.budget_limit !== 'number' || body.budget_limit < 0)) {
|
if (body.budget_limit !== undefined && (typeof body.budget_limit !== 'number' || body.budget_limit < 0)) {
|
||||||
invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' });
|
invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' });
|
||||||
}
|
}
|
||||||
@@ -617,38 +540,80 @@ export function getProviderBandwidthAllocation(providerName: string, memoryGb: n
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate bandwidth cost info for a server
|
* Calculate bandwidth cost info for a server
|
||||||
|
* Uses actual DB values from anvil_transfer_pricing when available
|
||||||
|
* @param server Server object
|
||||||
|
* @param bandwidthEstimate Bandwidth estimate
|
||||||
|
* @param lang Language code (ko = KRW, others = USD)
|
||||||
|
* @param exchangeRate Exchange rate (USD to KRW)
|
||||||
*/
|
*/
|
||||||
export function calculateBandwidthInfo(
|
export function calculateBandwidthInfo(
|
||||||
server: import('./types').Server,
|
server: import('./types').Server,
|
||||||
bandwidthEstimate: BandwidthEstimate
|
bandwidthEstimate: BandwidthEstimate,
|
||||||
|
lang: string = 'en',
|
||||||
|
exchangeRate: number = 1
|
||||||
): BandwidthInfo {
|
): BandwidthInfo {
|
||||||
const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb);
|
// Use actual DB values if available (Anvil servers), fallback to provider-based estimation
|
||||||
const estimatedTb = bandwidthEstimate.monthly_tb;
|
let includedTb: number;
|
||||||
const overageTb = Math.max(0, estimatedTb - allocation.included_tb);
|
let overagePerGbUsd: number;
|
||||||
const overageCost = overageTb * allocation.overage_per_tb;
|
let overagePerTbUsd: number;
|
||||||
|
|
||||||
// Convert server price to USD if needed for total cost calculation
|
if (server.transfer_tb !== null && server.transfer_price_per_gb !== null) {
|
||||||
|
// Use actual values from anvil_instances + anvil_transfer_pricing
|
||||||
|
includedTb = server.transfer_tb;
|
||||||
|
overagePerGbUsd = server.transfer_price_per_gb;
|
||||||
|
overagePerTbUsd = server.transfer_price_per_gb * 1024;
|
||||||
|
} else {
|
||||||
|
// Fallback to provider-based estimation for non-Anvil servers
|
||||||
|
const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb);
|
||||||
|
includedTb = allocation.included_tb;
|
||||||
|
overagePerGbUsd = allocation.overage_per_gb;
|
||||||
|
overagePerTbUsd = allocation.overage_per_tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedTb = bandwidthEstimate.monthly_tb;
|
||||||
|
const overageTb = Math.max(0, estimatedTb - includedTb);
|
||||||
|
const overageCostUsd = overageTb * overagePerTbUsd;
|
||||||
|
|
||||||
|
// Get server price in USD for total calculation
|
||||||
const serverPriceUsd = server.currency === 'KRW'
|
const serverPriceUsd = server.currency === 'KRW'
|
||||||
? server.monthly_price / 1400 // Approximate KRW to USD
|
? server.monthly_price / exchangeRate
|
||||||
: server.monthly_price;
|
: server.monthly_price;
|
||||||
|
|
||||||
const totalCost = serverPriceUsd + overageCost;
|
const totalCostUsd = serverPriceUsd + overageCostUsd;
|
||||||
|
|
||||||
|
// Convert to KRW if Korean language, round to nearest 100
|
||||||
|
const isKorean = lang === 'ko';
|
||||||
|
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
|
||||||
|
|
||||||
|
// KRW: GB당은 1원 단위, TB당/총 비용은 100원 단위 반올림
|
||||||
|
const roundKrw100 = (usd: number) => Math.round((usd * exchangeRate) / 100) * 100;
|
||||||
|
const toKrw = (usd: number) => Math.round(usd * exchangeRate);
|
||||||
|
|
||||||
|
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
|
||||||
|
const overagePerTb = isKorean ? roundKrw100(overagePerTbUsd) : overagePerTbUsd;
|
||||||
|
const overageCost = isKorean ? roundKrw100(overageCostUsd) : Math.round(overageCostUsd * 100) / 100;
|
||||||
|
const totalCost = isKorean ? roundKrw100(totalCostUsd) : Math.round(totalCostUsd * 100) / 100;
|
||||||
|
|
||||||
let warning: string | undefined;
|
let warning: string | undefined;
|
||||||
if (overageTb > allocation.included_tb) {
|
if (overageTb > includedTb) {
|
||||||
warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${allocation.included_tb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
|
const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
|
||||||
|
warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
|
||||||
} else if (overageTb > 0) {
|
} else if (overageTb > 0) {
|
||||||
warning = `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~$${overageCost.toFixed(0)}/월)`;
|
const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
|
||||||
|
warning = isKorean
|
||||||
|
? `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`
|
||||||
|
: `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
included_transfer_tb: allocation.included_tb,
|
included_transfer_tb: includedTb,
|
||||||
overage_cost_per_gb: allocation.overage_per_gb,
|
overage_cost_per_gb: isKorean ? Math.round(overagePerGb) : Math.round(overagePerGb * 10000) / 10000,
|
||||||
overage_cost_per_tb: allocation.overage_per_tb,
|
overage_cost_per_tb: isKorean ? Math.round(overagePerTb) : Math.round(overagePerTb * 100) / 100,
|
||||||
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
|
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
|
||||||
estimated_overage_tb: Math.round(overageTb * 10) / 10,
|
estimated_overage_tb: Math.round(overageTb * 10) / 10,
|
||||||
estimated_overage_cost: Math.round(overageCost * 100) / 100,
|
estimated_overage_cost: overageCost,
|
||||||
total_estimated_cost: Math.round(totalCost * 100) / 100,
|
total_estimated_cost: totalCost,
|
||||||
|
currency,
|
||||||
warning
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -657,16 +622,35 @@ export function calculateBandwidthInfo(
|
|||||||
* Sanitize user input for AI prompts to prevent prompt injection
|
* Sanitize user input for AI prompts to prevent prompt injection
|
||||||
*/
|
*/
|
||||||
export function sanitizeForAIPrompt(input: string, maxLength: number = 200): string {
|
export function sanitizeForAIPrompt(input: string, maxLength: number = 200): string {
|
||||||
// Remove potential prompt injection patterns
|
// 1. Normalize Unicode (NFKC form collapses homoglyphs)
|
||||||
let sanitized = input
|
let sanitized = input.normalize('NFKC');
|
||||||
.replace(/ignore\s*(all|previous|above)?\s*instruction/gi, '[filtered]')
|
|
||||||
.replace(/system\s*prompt/gi, '[filtered]')
|
// 2. Remove zero-width characters
|
||||||
.replace(/you\s*are\s*(now|a)/gi, '[filtered]')
|
sanitized = sanitized.replace(/[\u200B-\u200D\uFEFF\u00AD]/g, '');
|
||||||
.replace(/pretend\s*(to\s*be|you)/gi, '[filtered]')
|
|
||||||
.replace(/act\s*as/gi, '[filtered]')
|
// 3. Expanded blocklist patterns
|
||||||
.replace(/disregard/gi, '[filtered]');
|
const dangerousPatterns = [
|
||||||
|
/ignore\s*(all|previous|above)?\s*instruction/gi,
|
||||||
|
/system\s*prompt/gi,
|
||||||
|
/you\s*are\s*(now|a)/gi,
|
||||||
|
/pretend\s*(to\s*be|you)/gi,
|
||||||
|
/act\s*as/gi,
|
||||||
|
/disregard/gi,
|
||||||
|
/forget\s*(everything|all|previous)/gi,
|
||||||
|
/new\s*instruction/gi,
|
||||||
|
/override/gi,
|
||||||
|
/\[system\]/gi,
|
||||||
|
/<\|im_start\|>/gi,
|
||||||
|
/<\|im_end\|>/gi,
|
||||||
|
/```[\s\S]*?```/g, // Code blocks that might contain injection
|
||||||
|
/"""/g, // Triple quotes
|
||||||
|
/---+/g, // Horizontal rules/delimiters
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of dangerousPatterns) {
|
||||||
|
sanitized = sanitized.replace(pattern, '[filtered]');
|
||||||
|
}
|
||||||
|
|
||||||
// Limit length
|
|
||||||
return sanitized.slice(0, maxLength);
|
return sanitized.slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,10 +682,16 @@ export async function getExchangeRate(env: Env): Promise<number> {
|
|||||||
|
|
||||||
// Fetch fresh rate from API
|
// Fetch fresh rate from API
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||||
|
|
||||||
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
|
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API returned ${response.status}`);
|
throw new Error(`API returned ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -733,7 +723,11 @@ export async function getExchangeRate(env: Env): Promise<number> {
|
|||||||
|
|
||||||
return rate;
|
return rate;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ExchangeRate] API error:', error);
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.warn('[ExchangeRate] Request timed out, using fallback');
|
||||||
|
} else {
|
||||||
|
console.error('[ExchangeRate] API error:', error);
|
||||||
|
}
|
||||||
return EXCHANGE_RATE_FALLBACK;
|
return EXCHANGE_RATE_FALLBACK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name = "server-recommend"
|
name = "cloud-orchestrator"
|
||||||
main = "src/index.ts"
|
main = "src/index.ts"
|
||||||
compatibility_date = "2025-01-23"
|
compatibility_date = "2025-01-23"
|
||||||
compatibility_flags = ["nodejs_compat"]
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|||||||
Reference in New Issue
Block a user