From 411cde4801ca5cc9c3062acf241b6533f1e5d74c Mon Sep 17 00:00:00 2001 From: kappa Date: Mon, 26 Jan 2026 02:49:24 +0900 Subject: [PATCH] feat: add region diversity, HTML report, and transfer pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 96 +++++- package.json | 2 +- src/config.ts | 5 + src/handlers/recommend.ts | 236 +++++++------- src/handlers/report.ts | 639 ++++++++++++++++++++++++++++++++++++++ src/handlers/servers.ts | 118 ++----- src/index.ts | 5 + src/region-utils.ts | 137 ++++++++ src/types.ts | 10 +- src/utils.ts | 238 +++++++------- wrangler.toml | 2 +- 11 files changed, 1132 insertions(+), 356 deletions(-) create mode 100644 src/handlers/report.ts create mode 100644 src/region-utils.ts diff --git a/CLAUDE.md b/CLAUDE.md index 54e2e22..1842382 100644 --- a/CLAUDE.md +++ b/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. -**Production URL**: `https://server-recommend.kappa-d8e.workers.dev` +**Production URL**: `https://cloud-orchestrator.kappa-d8e.workers.dev` ## Commands @@ -34,10 +34,12 @@ src/ ├── config.ts # Configuration constants ├── types.ts # TypeScript type definitions ├── utils.ts # Utilities (bandwidth, response, AI, benchmarks, candidates, techSpecs) +├── region-utils.ts # Region matching utilities (flexible region conditions) └── handlers/ ├── health.ts # GET /api/health ├── 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 @@ -52,9 +54,10 @@ src/ ### D1 Database Tables (cloud-instances-db) **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_pricing` - Anvil pricing data (monthly_price in USD) +- `anvil_transfer_pricing` - Transfer/bandwidth overage pricing by region (price_per_gb in USD) **Support tables**: - `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% | | 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 @@ -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 - 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`) - 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_id uses `ap.id`** (pricing ID, unique per instance+region combination) - Scoring: Cost efficiency (40%) + Capacity fit (30%) + Scalability (30%) - Capacity response in Korean for Korean users - **Prompt injection protection**: User inputs sanitized via `sanitizeForAIPrompt()` @@ -168,24 +223,45 @@ OPENAI_API_KEY = "sk-..." # Set via wrangler secret ```bash # 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) -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) -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) -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.) -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 -### 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 - **Provider**: Now uses "Anvil" as single provider (previously Linode/Vultr) - **Exchange rate**: Real-time USD→KRW conversion via open.er-api.com diff --git a/package.json b/package.json index 5156c84..e765d07 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "server-recommend", + "name": "cloud-orchestrator", "version": "1.0.0", "description": "", "main": "index.js", diff --git a/src/config.ts b/src/config.ts index 89288ce..8fc3498 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,6 +76,7 @@ export const USE_CASE_CONFIGS: UseCaseConfig[] = [ export const i18n: Record; example: Record; aiLanguageInstruction: string; @@ -83,6 +84,7 @@ export const i18n: Record { const message = err instanceof Error ? err.message : String(err); console.warn('[Recommend] VPS benchmarks unavailable:', message); 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] VPS benchmark data points:', vpsBenchmarks.length); @@ -265,7 +294,8 @@ export async function handleRecommend( vpsBenchmarks, techSpecs, bandwidthEstimate, - lang + lang, + exchangeRate ); console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length); @@ -315,17 +345,18 @@ async function queryCandidateServers( minMemoryMb?: number, minVcpu?: number, bandwidthEstimate?: BandwidthEstimate, - lang: string = 'en' + lang: string = 'en', + exchangeRate: number = 1 ): Promise { - // Get exchange rate for KRW display (Korean users) - const exchangeRate = lang === 'ko' ? await getExchangeRate(env) : 1; - const currency = lang === 'ko' ? 'KRW' : 'USD'; + // Currency display based on language (exchange rate applied in handleRecommend) + const currency = 'USD'; // Always return USD prices, converted to KRW in handleRecommend if needed // Build query using anvil_* tables // anvil_pricing.monthly_price is stored in USD + // Join anvil_transfer_pricing for overage bandwidth costs let query = ` SELECT - ai.id, + ap.id, 'Anvil' as provider_name, ai.name as instance_id, ai.display_name as instance_name, @@ -340,20 +371,22 @@ async function queryCandidateServers( ap.monthly_price as monthly_price_usd, ar.display_name as region_name, 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 JOIN anvil_pricing ap ON ap.anvil_instance_id = ai.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 `; 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) { - const budgetUsd = lang === 'ko' ? req.budget_limit / exchangeRate : req.budget_limit; query += ` AND ap.monthly_price <= ?`; - params.push(budgetUsd); + params.push(req.budget_limit); } // Filter by minimum memory requirement (from tech specs) @@ -372,19 +405,18 @@ async function queryCandidateServers( console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`); } - // Flexible region matching using anvil_regions table - // r.* aliases need to change to ar.* for anvil_regions + // Filter by region preference if specified if (req.region_preference && req.region_preference.length > 0) { const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil(req.region_preference); - query += ` AND (${conditions.join(' OR ')})`; - params.push(...regionParams); - } else { - // No region specified → default to Seoul/Tokyo/Singapore - query += ` AND ${DEFAULT_ANVIL_REGION_FILTER_SQL}`; + if (conditions.length > 0) { + query += ` AND (${conditions.join(' OR ')})`; + params.push(...regionParams); + console.log(`[Candidates] Filtering by regions: ${req.region_preference.join(', ')}`); + } } - // Order by price - query += ` ORDER BY ap.monthly_price ASC LIMIT 50`; + // Order by price - return ALL matching servers across all regions + query += ` ORDER BY ap.monthly_price ASC`; const result = await db.prepare(query).bind(...params).all(); @@ -392,17 +424,17 @@ async function queryCandidateServers( 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 => { if (typeof server === 'object' && server !== null) { const s = server as Record; - 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 { ...s, - monthly_price: displayPrice, - currency + monthly_price: s.monthly_price_usd as number, + currency, + transfer_tb: s.transfer_tb as number | null, + transfer_price_per_gb: s.transfer_price_per_gb as number | null }; } return server; @@ -413,83 +445,11 @@ async function queryCandidateServers( if (invalidCount > 0) { console.warn(`[Candidates] Filtered out ${invalidCount} invalid server records`); } + + console.log(`[Candidates] Found ${validServers.length} servers matching technical requirements (all regions)`); 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 = { - '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 */ @@ -832,7 +792,8 @@ async function getAIRecommendations( vpsBenchmarks: VPSBenchmark[], techSpecs: TechSpec[], bandwidthEstimate: BandwidthEstimate, - lang: string = 'en' + lang: string = 'en', + exchangeRate: number = 1 ): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> { // Validate API key before making any API calls 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. 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. - 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 ${techSpecsPrompt} @@ -890,10 +845,35 @@ ${languageInstruction}`; const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks); // Pre-filter candidates to reduce AI prompt size and cost - // Sort by price and limit to top 15 most affordable options - const topCandidates = candidates - .sort((a, b) => a.monthly_price - b.monthly_price) - .slice(0, 15); + // Ensure region diversity when no region_preference is specified + let topCandidates: Server[]; + const hasRegionPreference = req.region_preference && req.region_preference.length > 0; + + 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(); + 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`); @@ -910,8 +890,7 @@ ${languageInstruction}`; - Use Case: ${sanitizedUseCase} - Traffic Pattern: ${req.traffic_pattern || 'steady'} - **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.` : ''} -${req.region_preference ? `- Region Preference: ${req.region_preference.join(', ')}` : ''} +${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): Consider bandwidth overage costs when evaluating total cost.` : ''} ${req.budget_limit ? `- Budget Limit: $${req.budget_limit}/month` : ''} ## 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.'} ## Available Servers (IMPORTANT: Use the server_id value, NOT the list number!) -${topCandidates.map((s) => ` -[server_id=${s.id}] ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''} - Instance: ${s.instance_id} - 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')} +${topCandidates.map((s) => `[server_id=${s.id}] ${s.provider_name} - ${s.instance_name} + 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}` : ''} + 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')} 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: -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 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 || ''; console.log('[AI] Response received from OpenAI, length:', response.length); - console.log('[AI] Raw response preview:', response.substring(0, 500)); // Parse AI 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 - const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate); + // Calculate bandwidth info for this server (with currency conversion for Korean) + const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate, lang, exchangeRate); // Find all available regions for the same server spec const availableRegions: AvailableRegion[] = candidates @@ -1233,7 +1207,7 @@ function parseAIResponse(response: unknown): AIRecommendationResponse { } as AIRecommendationResponse; } catch (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'}`); } } diff --git a/src/handlers/report.ts b/src/handlers/report.ts new file mode 100644 index 0000000..e6ee20b --- /dev/null +++ b/src/handlers/report.ts @@ -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> = { + 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 { + 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 { + 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 +): Promise { + 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, + 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 ` +
+
+
${tierLabel}
+
${labels.score}: ${score}/100
+
+ +

${server.provider_name} - ${server.instance_name}

+ +
+
+ ${labels.vcpu} + ${server.vcpu} cores +
+
+ ${labels.memory} + ${server.memory_gb} GB +
+
+ ${labels.storage} + ${server.storage_gb} GB +
+
+ ${labels.region} + ${server.region_name} +
+
+ ${labels.price} + ${formatPrice(server.monthly_price, currency)}${labels.perMonth} +
+
+ + ${bandwidth_info ? ` +
+

${labels.bandwidthInfo}

+
+
+ ${labels.includedTransfer} + ${bandwidth_info.included_transfer_tb} TB${labels.perMonth} +
+
+ ${labels.overagePrice} + ${bandwidth_info.currency === 'KRW' ? `₩${bandwidth_info.overage_cost_per_gb}` : `$${bandwidth_info.overage_cost_per_gb.toFixed(4)}`}${labels.perGb} +
+
+ ${labels.estimatedUsage} + ${bandwidth_info.estimated_monthly_tb} TB +
+ ${bandwidth_info.estimated_overage_tb > 0 ? ` +
+ ${labels.estimatedOverage} + ${bandwidth_info.estimated_overage_tb} TB +
+
+ ${labels.estimatedCost} + +${bandwidth_info.currency === 'KRW' ? `₩${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}` : `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`} +
+ ` : ''} +
+ ${labels.totalCost} + ${bandwidth_info.currency === 'KRW' ? `₩${Math.round(bandwidth_info.total_estimated_cost).toLocaleString()}` : `$${bandwidth_info.total_estimated_cost.toFixed(2)}`}${labels.perMonth} +
+
+ ${bandwidth_info.warning ? `
${bandwidth_info.warning}
` : ''} +
+ ` : ''} + +
+

${labels.analysis}

+
+ ${labels.techFit}: ${analysis.tech_fit} +
+
+ ${labels.capacity}: ${analysis.capacity} +
+
+ ${labels.costEfficiency}: ${analysis.cost_efficiency} +
+
+ ${labels.scalability}: ${analysis.scalability} +
+
+
+ `; + }).join('\n'); + + return ` + + + + + ${labels.title} + + + +
+
+

${labels.title}

+

${labels.subtitle}

+
+ + + + ${request ? ` +
+

${labels.requirements}

+
+
+ ${labels.techStack} + ${request.tech_stack.join(', ')} +
+
+ ${labels.expectedUsers} + ${request.expected_users.toLocaleString()} +
+
+ ${labels.useCase} + ${request.use_case} +
+
+ + ${bandwidth_estimate ? ` +
+

${labels.bandwidthEstimate}

+
+ ${labels.monthly}: ${bandwidth_estimate.monthly_tb >= 1 ? `${bandwidth_estimate.monthly_tb} TB` : `${bandwidth_estimate.monthly_gb} GB`} + ${labels.daily}: ${bandwidth_estimate.daily_gb} GB + ${labels.category}: ${bandwidth_estimate.category} +
+
+ ` : ''} +
+ ` : ''} + +
+

${labels.recommendations}

+ ${recommendationCards} +
+ +
+

${labels.generatedAt}: ${now}

+

${labels.poweredBy}

+
+
+ +`; +} diff --git a/src/handlers/servers.ts b/src/handlers/servers.ts index 5a6fd59..1f5ed31 100644 --- a/src/handlers/servers.ts +++ b/src/handlers/servers.ts @@ -4,85 +4,10 @@ import type { Env } from '../types'; import { jsonResponse, isValidServer } from '../utils'; - -/** - * 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%' -)`; - -/** - * Country name to region code/city mapping for flexible region matching - */ -const COUNTRY_NAME_TO_REGIONS: Record = { - '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 }; -} +import { + DEFAULT_ANVIL_REGION_FILTER_SQL, + buildFlexibleRegionConditionsAnvil +} from '../region-utils'; /** * GET /api/servers - Server list with filtering @@ -105,6 +30,18 @@ export async function handleGetServers( 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 let query = ` SELECT @@ -182,15 +119,20 @@ export async function handleGetServers( console.log('[GetServers] Found servers:', servers.length); - return jsonResponse( - { - servers, - count: servers.length, - filters: { minCpu, minMemory, region }, - }, - 200, - corsHeaders - ); + const responseData = { + servers, + count: servers.length, + filters: { minCpu, minMemory, region }, + }; + + // Cache successful results (only if we have servers) + 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) { console.error('[GetServers] Error:', error); const requestId = crypto.randomUUID(); diff --git a/src/index.ts b/src/index.ts index 43d8213..826df40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { getAllowedOrigin, checkRateLimit, jsonResponse } from './utils'; import { handleHealth } from './handlers/health'; import { handleGetServers } from './handlers/servers'; import { handleRecommend } from './handlers/recommend'; +import { handleReport } from './handlers/report'; /** * Main request handler @@ -66,6 +67,10 @@ export default { return handleRecommend(request, env, corsHeaders); } + if (path === '/api/recommend/report' && request.method === 'GET') { + return handleReport(request, env, corsHeaders); + } + return jsonResponse( { error: 'Not found', request_id: requestId }, 404, diff --git a/src/region-utils.ts b/src/region-utils.ts new file mode 100644 index 0000000..1de19bf --- /dev/null +++ b/src/region-utils.ts @@ -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 = { + '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 }; +} diff --git a/src/types.ts b/src/types.ts index 2d8c49a..e62446d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,16 +50,20 @@ export interface Server { currency: 'USD' | 'KRW'; region_name: 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 { included_transfer_tb: number; // 기본 포함 트래픽 (TB/월) - overage_cost_per_gb: number; // 초과 비용 ($/GB) - overage_cost_per_tb: number; // 초과 비용 ($/TB) + overage_cost_per_gb: number; // 초과 비용 ($/GB 또는 ₩/GB) + overage_cost_per_tb: number; // 초과 비용 ($/TB 또는 ₩/TB) estimated_monthly_tb: number; // 예상 월간 사용량 (TB) estimated_overage_tb: number; // 예상 초과량 (TB) - estimated_overage_cost: number; // 예상 초과 비용 ($) + estimated_overage_cost: number; // 예상 초과 비용 total_estimated_cost: number; // 총 예상 비용 (서버 + 트래픽) + currency: 'USD' | 'KRW'; // 통화 warning?: string; // 트래픽 관련 경고 } diff --git a/src/utils.ts b/src/utils.ts index a7b6abe..54deb0e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -88,16 +88,17 @@ export function generateCacheKey(req: RecommendRequest): string { 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) { 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 if (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 - * Used in both /api/recommend and /api/servers + * Re-export region utilities from region-utils.ts 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%' -)`; - -/** - * 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 = { - '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 }; -} +export { + DEFAULT_ANVIL_REGION_FILTER_SQL, + COUNTRY_NAME_TO_REGIONS, + escapeLikePattern, + buildFlexibleRegionConditions, + buildFlexibleRegionConditionsAnvil +} from './region-utils'; /** * 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' }); } else if (body.tech_stack.length > LIMITS.MAX_TECH_STACK) { 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')) { - invalidFields.push({ field: 'tech_stack', reason: 'all items must be strings' }); + } else if (!body.tech_stack.every((item: unknown) => + 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) { @@ -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'" }); } - 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)) { 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 + * 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( server: import('./types').Server, - bandwidthEstimate: BandwidthEstimate + bandwidthEstimate: BandwidthEstimate, + lang: string = 'en', + exchangeRate: number = 1 ): BandwidthInfo { - const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb); - const estimatedTb = bandwidthEstimate.monthly_tb; - const overageTb = Math.max(0, estimatedTb - allocation.included_tb); - const overageCost = overageTb * allocation.overage_per_tb; + // Use actual DB values if available (Anvil servers), fallback to provider-based estimation + let includedTb: number; + let overagePerGbUsd: number; + 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' - ? server.monthly_price / 1400 // Approximate KRW to USD + ? server.monthly_price / exchangeRate : 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; - if (overageTb > allocation.included_tb) { - warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${allocation.included_tb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`; + if (overageTb > includedTb) { + const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`; + warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`; } 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 { - included_transfer_tb: allocation.included_tb, - overage_cost_per_gb: allocation.overage_per_gb, - overage_cost_per_tb: allocation.overage_per_tb, + included_transfer_tb: includedTb, + overage_cost_per_gb: isKorean ? Math.round(overagePerGb) : Math.round(overagePerGb * 10000) / 10000, + overage_cost_per_tb: isKorean ? Math.round(overagePerTb) : Math.round(overagePerTb * 100) / 100, estimated_monthly_tb: Math.round(estimatedTb * 10) / 10, estimated_overage_tb: Math.round(overageTb * 10) / 10, - estimated_overage_cost: Math.round(overageCost * 100) / 100, - total_estimated_cost: Math.round(totalCost * 100) / 100, + estimated_overage_cost: overageCost, + total_estimated_cost: totalCost, + currency, warning }; } @@ -657,16 +622,35 @@ export function calculateBandwidthInfo( * Sanitize user input for AI prompts to prevent prompt injection */ export function sanitizeForAIPrompt(input: string, maxLength: number = 200): string { - // Remove potential prompt injection patterns - let sanitized = input - .replace(/ignore\s*(all|previous|above)?\s*instruction/gi, '[filtered]') - .replace(/system\s*prompt/gi, '[filtered]') - .replace(/you\s*are\s*(now|a)/gi, '[filtered]') - .replace(/pretend\s*(to\s*be|you)/gi, '[filtered]') - .replace(/act\s*as/gi, '[filtered]') - .replace(/disregard/gi, '[filtered]'); + // 1. Normalize Unicode (NFKC form collapses homoglyphs) + let sanitized = input.normalize('NFKC'); + + // 2. Remove zero-width characters + sanitized = sanitized.replace(/[\u200B-\u200D\uFEFF\u00AD]/g, ''); + + // 3. Expanded blocklist patterns + 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); } @@ -698,10 +682,16 @@ export async function getExchangeRate(env: Env): Promise { // Fetch fresh rate from API 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', { headers: { 'Accept': 'application/json' }, + signal: controller.signal, }); + clearTimeout(timeoutId); + if (!response.ok) { throw new Error(`API returned ${response.status}`); } @@ -733,7 +723,11 @@ export async function getExchangeRate(env: Env): Promise { return rate; } 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; } } diff --git a/wrangler.toml b/wrangler.toml index 883c450..3b28c3f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,4 @@ -name = "server-recommend" +name = "cloud-orchestrator" main = "src/index.ts" compatibility_date = "2025-01-23" compatibility_flags = ["nodejs_compat"]