diff --git a/CLAUDE.md b/CLAUDE.md index 957af96..54e2e22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,14 +51,19 @@ src/ ### D1 Database Tables (cloud-instances-db) -- `providers` - Cloud providers (50+) -- `instance_types` - Server specifications -- `pricing` - Regional pricing -- `regions` - Geographic regions +**Primary tables (Anvil pricing)**: +- `anvil_instances` - Anvil server specifications (vcpus, memory_gb, disk_gb, etc.) +- `anvil_regions` - Anvil data center regions (name, display_name, country_code) +- `anvil_pricing` - Anvil pricing data (monthly_price in USD) + +**Support tables**: - `tech_specs` - Resource requirements per technology (vcpu_per_users, min_memory_mb) - `vps_benchmarks` - Geekbench 6 benchmark data (269 records) - `benchmark_results` / `benchmark_types` / `processors` - Phoronix benchmark data +**Legacy tables (no longer used)**: +- `providers`, `instance_types`, `pricing`, `regions` - Old Linode/Vultr data + ## Key Implementation Details ### DB Workload Multiplier (`recommend.ts`) @@ -90,20 +95,26 @@ Estimates monthly bandwidth based on use_case patterns: Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth. -### Flexible Region Matching (`utils.ts`) +### Flexible Region Matching -Both `/api/recommend` and `/api/servers` use shared `buildFlexibleRegionConditions()`: +Both `/api/recommend` and `/api/servers` use `buildFlexibleRegionConditionsAnvil()` for anvil_regions: ```sql -LOWER(r.region_code) = ? OR -LOWER(r.region_code) LIKE ? OR -LOWER(r.region_name) LIKE ? OR -LOWER(r.country_code) = ? +LOWER(ar.name) = ? OR +LOWER(ar.name) LIKE ? OR +LOWER(ar.display_name) LIKE ? OR +LOWER(ar.country_code) = ? ``` Valid inputs: `"korea"`, `"KR"`, `"seoul"`, `"tokyo"`, `"japan"`, `"ap-northeast-2"`, `"icn"` Country names are auto-expanded via `COUNTRY_NAME_TO_REGIONS` mapping. +### Exchange Rate Handling (`utils.ts`) + +- Korean users (lang=ko) see prices in KRW +- Exchange rate fetched from open.er-api.com with 1-hour KV cache +- Fallback rate: 1450 KRW/USD if API unavailable + ### AI Prompt Strategy (`recommend.ts`) - Uses OpenAI GPT-4o-mini via Cloudflare AI Gateway (bypasses regional restrictions) @@ -174,6 +185,13 @@ curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea ## Recent Changes +### Anvil Pricing Migration (Latest) +- **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 +- **Removed**: `provider_filter` parameter no longer supported +- **Currency handling**: Korean users see KRW, others see USD + ### Architecture - **Modular architecture**: Split from single 2370-line file into organized modules - **Centralized config**: All magic numbers moved to `LIMITS` in config.ts @@ -197,6 +215,4 @@ curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea ### Code Quality - **Dead code removed**: Unused `queryVPSBenchmarks` function deleted -- **DRY**: `DEFAULT_REGION_FILTER_SQL` and `buildFlexibleRegionConditions()` shared between handlers -- **Name-based filtering**: Provider queries use names, not hardcoded IDs - **Flexible region matching**: Both endpoints support country/city/code inputs (korea, seoul, icn) diff --git a/src/config.ts b/src/config.ts index 5de5d89..89288ce 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,7 +90,6 @@ export const i18n: Record { const message = err instanceof Error ? err.message : String(err); console.warn('[Recommend] VPS benchmarks unavailable:', message); @@ -312,104 +310,81 @@ export async function handleRecommend( } async function queryCandidateServers( db: D1Database, + env: Env, req: RecommendRequest, minMemoryMb?: number, minVcpu?: number, bandwidthEstimate?: BandwidthEstimate, lang: string = 'en' ): Promise { - // Select price column based on language - // Korean → monthly_price_krw (KRW), Others → monthly_price_retail (1.21x USD) - const priceColumn = lang === 'ko' ? 'pr.monthly_price_krw' : 'pr.monthly_price_retail'; + // Get exchange rate for KRW display (Korean users) + const exchangeRate = lang === 'ko' ? await getExchangeRate(env) : 1; const currency = lang === 'ko' ? 'KRW' : 'USD'; - // Check if region preference is specified - const hasRegionPref = req.region_preference && req.region_preference.length > 0; - + // Build query using anvil_* tables + // anvil_pricing.monthly_price is stored in USD let query = ` SELECT - it.id, - p.display_name as provider_name, - it.instance_id, - it.instance_name, - it.vcpu, - it.memory_mb, - ROUND(it.memory_mb / 1024.0, 1) as memory_gb, - it.storage_gb, - it.network_speed_gbps, - it.instance_family, - it.gpu_count, - it.gpu_type, - MIN(${priceColumn}) as monthly_price, - r.region_name as region_name, - r.region_code as region_code, - r.country_code as country_code - FROM instance_types it - JOIN providers p ON it.provider_id = p.id - JOIN pricing pr ON pr.instance_type_id = it.id - JOIN regions r ON pr.region_id = r.id - WHERE LOWER(p.name) IN ('linode', 'vultr') -- Linode, Vultr only + ai.id, + 'Anvil' as provider_name, + ai.name as instance_id, + ai.display_name as instance_name, + ai.vcpus as vcpu, + CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb, + ai.memory_gb, + ai.disk_gb as storage_gb, + ai.network_gbps as network_speed_gbps, + ai.category as instance_family, + CASE WHEN ai.gpu_model IS NOT NULL THEN 1 ELSE 0 END as gpu_count, + ai.gpu_model as gpu_type, + ap.monthly_price as monthly_price_usd, + ar.display_name as region_name, + ar.name as region_code, + ar.country_code + 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 + WHERE ai.active = 1 AND ar.active = 1 `; const params: (string | number)[] = []; + // Filter by budget limit (convert to USD for comparison) if (req.budget_limit) { - // Use same price column as display for budget filtering - query += ` AND ${priceColumn} <= ?`; - params.push(req.budget_limit); + const budgetUsd = lang === 'ko' ? req.budget_limit / exchangeRate : req.budget_limit; + query += ` AND ap.monthly_price <= ?`; + params.push(budgetUsd); } // Filter by minimum memory requirement (from tech specs) + // Note: anvil_instances uses memory_gb, so convert minMemoryMb to GB if (minMemoryMb && minMemoryMb > 0) { - query += ` AND it.memory_mb >= ?`; - params.push(minMemoryMb); + const minMemoryGb = minMemoryMb / 1024; + query += ` AND ai.memory_gb >= ?`; + params.push(minMemoryGb); console.log(`[Candidates] Filtering by minimum memory: ${minMemoryMb}MB (${(minMemoryMb/1024).toFixed(1)}GB)`); } // Filter by minimum vCPU requirement (from expected users + tech specs) if (minVcpu && minVcpu > 0) { - query += ` AND it.vcpu >= ?`; + query += ` AND ai.vcpus >= ?`; params.push(minVcpu); console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`); } - // Provider preference based on bandwidth requirements (no hard filtering to avoid empty results) - // Heavy/Very heavy bandwidth → Prefer Linode (better bandwidth allowance), but allow all providers - // AI prompt will warn about bandwidth costs for non-Linode providers - if (bandwidthEstimate) { - if (bandwidthEstimate.category === 'very_heavy') { - // >6TB/month: Strongly prefer Linode, but don't exclude others (Linode may not be available in all regions) - console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode strongly preferred, all providers included`); - } else if (bandwidthEstimate.category === 'heavy') { - // 2-6TB/month: Prefer Linode - console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`); - } - } - - // Flexible region matching: region_code, region_name, or country_code + // Flexible region matching using anvil_regions table + // r.* aliases need to change to ar.* for anvil_regions if (req.region_preference && req.region_preference.length > 0) { - const { conditions, params: regionParams } = buildFlexibleRegionConditions(req.region_preference); + const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil(req.region_preference); query += ` AND (${conditions.join(' OR ')})`; params.push(...regionParams); } else { - // No region specified → default to Seoul/Tokyo/Osaka/Singapore - query += ` AND ${DEFAULT_REGION_FILTER_SQL}`; + // No region specified → default to Seoul/Tokyo/Singapore + query += ` AND ${DEFAULT_ANVIL_REGION_FILTER_SQL}`; } - // Filter by provider if specified - if (req.provider_filter && req.provider_filter.length > 0) { - const placeholders = req.provider_filter.map(() => '?').join(','); - query += ` AND (p.name IN (${placeholders}) OR p.display_name IN (${placeholders}))`; - params.push(...req.provider_filter, ...req.provider_filter); - } - - // Group by instance + region to show each server per region - // For heavy/very_heavy bandwidth, prioritize Linode due to generous bandwidth allowance - const isHighBandwidth = bandwidthEstimate?.category === 'heavy' || bandwidthEstimate?.category === 'very_heavy'; - const orderByClause = isHighBandwidth - ? `ORDER BY CASE WHEN LOWER(p.name) = 'linode' THEN 0 ELSE 1 END, monthly_price ASC` - : `ORDER BY monthly_price ASC`; - query += ` GROUP BY it.id, r.id ${orderByClause} LIMIT 50`; + // Order by price + query += ` ORDER BY ap.monthly_price ASC LIMIT 50`; const result = await db.prepare(query).bind(...params).all(); @@ -417,13 +392,22 @@ async function queryCandidateServers( throw new Error('Failed to query candidate servers'); } - // Add currency to each result and validate with type guard + // Convert USD prices to display currency and validate const serversWithCurrency = (result.results as unknown[]).map(server => { if (typeof server === 'object' && server !== null) { - return { ...server, currency }; + 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 + }; } return server; }); + const validServers = serversWithCurrency.filter(isValidServer); const invalidCount = result.results.length - validServers.length; if (invalidCount > 0) { @@ -432,6 +416,80 @@ async function queryCandidateServers( 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 */ diff --git a/src/handlers/servers.ts b/src/handlers/servers.ts index 6647889..5a6fd59 100644 --- a/src/handlers/servers.ts +++ b/src/handlers/servers.ts @@ -3,10 +3,90 @@ */ import type { Env } from '../types'; -import { jsonResponse, isValidServer, DEFAULT_REGION_FILTER_SQL, buildFlexibleRegionConditions } from '../utils'; +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 }; +} /** * GET /api/servers - Server list with filtering + * Uses anvil_* tables for pricing data */ export async function handleGetServers( request: Request, @@ -15,57 +95,49 @@ export async function handleGetServers( ): Promise { try { const url = new URL(request.url); - const provider = url.searchParams.get('provider'); const minCpu = url.searchParams.get('minCpu'); const minMemory = url.searchParams.get('minMemory'); const region = url.searchParams.get('region'); console.log('[GetServers] Query params:', { - provider, minCpu, minMemory, region, }); - // Build SQL query dynamically + // Build SQL query using anvil_* tables let query = ` SELECT - it.id, - p.display_name as provider_name, - it.instance_id, - it.instance_name, - it.vcpu, - it.memory_mb, - ROUND(it.memory_mb / 1024.0, 1) as memory_gb, - it.storage_gb, - it.network_speed_gbps, - it.instance_family, - it.gpu_count, - it.gpu_type, - pr.monthly_price, - r.region_name, - r.region_code - FROM instance_types it - JOIN providers p ON it.provider_id = p.id - JOIN pricing pr ON pr.instance_type_id = it.id - JOIN regions r ON pr.region_id = r.id - WHERE LOWER(p.name) IN ('linode', 'vultr') - AND ${DEFAULT_REGION_FILTER_SQL} + ai.id, + 'Anvil' as provider_name, + ai.name as instance_id, + ai.display_name as instance_name, + ai.vcpus as vcpu, + CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb, + ai.memory_gb, + ai.disk_gb as storage_gb, + ai.network_gbps as network_speed_gbps, + ai.category as instance_family, + CASE WHEN ai.gpu_model IS NOT NULL THEN 1 ELSE 0 END as gpu_count, + ai.gpu_model as gpu_type, + ap.monthly_price, + ar.display_name as region_name, + ar.name as region_code + 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 + WHERE ai.active = 1 AND ar.active = 1 + AND ${DEFAULT_ANVIL_REGION_FILTER_SQL} `; const params: (string | number)[] = []; - if (provider) { - query += ` AND p.name = ?`; - params.push(provider); - } - if (minCpu) { const parsedCpu = parseInt(minCpu, 10); if (isNaN(parsedCpu)) { return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders); } - query += ` AND it.vcpu >= ?`; + query += ` AND ai.vcpus >= ?`; params.push(parsedCpu); } @@ -74,18 +146,19 @@ export async function handleGetServers( if (isNaN(parsedMemory)) { return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders); } - query += ` AND it.memory_mb >= ?`; - params.push(parsedMemory * 1024); + // minMemory is in GB, anvil_instances stores memory_gb + query += ` AND ai.memory_gb >= ?`; + params.push(parsedMemory); } if (region) { // Flexible region matching: supports country names, codes, city names - const { conditions, params: regionParams } = buildFlexibleRegionConditions([region]); + const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil([region]); query += ` AND (${conditions.join(' OR ')})`; params.push(...regionParams); } - query += ` GROUP BY it.id, r.id ORDER BY pr.monthly_price ASC LIMIT 100`; + query += ` ORDER BY ap.monthly_price ASC LIMIT 100`; const result = await env.DB.prepare(query).bind(...params).all(); @@ -93,8 +166,15 @@ export async function handleGetServers( throw new Error('Database query failed'); } - // Validate each result with type guard - const servers = (result.results as unknown[]).filter(isValidServer); + // Add USD currency to each result and validate with type guard + const serversWithCurrency = (result.results as unknown[]).map(server => { + if (typeof server === 'object' && server !== null) { + return { ...server, currency: 'USD' }; + } + return server; + }); + + const servers = serversWithCurrency.filter(isValidServer); const invalidCount = result.results.length - servers.length; if (invalidCount > 0) { console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`); @@ -106,7 +186,7 @@ export async function handleGetServers( { servers, count: servers.length, - filters: { provider, minCpu, minMemory, region }, + filters: { minCpu, minMemory, region }, }, 200, corsHeaders diff --git a/src/types.ts b/src/types.ts index 793f202..2d8c49a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,10 +25,14 @@ export interface RecommendRequest { traffic_pattern?: 'steady' | 'spiky' | 'growing'; region_preference?: string[]; budget_limit?: number; - provider_filter?: string[]; // Filter by specific providers (e.g., ["Linode", "Vultr"]) lang?: 'en' | 'zh' | 'ja' | 'ko'; // Response language } +export interface ExchangeRateCache { + rate: number; + timestamp: number; +} + export interface Server { id: number; provider_name: string; diff --git a/src/utils.ts b/src/utils.ts index 4531dc3..a7b6abe 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,7 +12,9 @@ import type { AIRecommendationResponse, UseCaseConfig, BandwidthEstimate, - BandwidthInfo + BandwidthInfo, + Env, + ExchangeRateCache } from './types'; import { USE_CASE_CONFIGS, i18n, LIMITS } from './config'; @@ -96,12 +98,6 @@ export function generateCacheKey(req: RecommendRequest): string { parts.push(`budget:${req.budget_limit}`); } - if (req.provider_filter && req.provider_filter.length > 0) { - const sortedProviders = [...req.provider_filter].sort(); - const sanitizedProviders = sortedProviders.map(sanitizeCacheValue).join(','); - parts.push(`prov:${sanitizedProviders}`); - } - // Include language in cache key if (req.lang) { parts.push(`lang:${req.lang}`); @@ -331,16 +327,6 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' }); } - if (body.provider_filter !== undefined) { - if (!Array.isArray(body.provider_filter)) { - invalidFields.push({ field: 'provider_filter', reason: 'must be an array' }); - } else if (body.provider_filter.length > 10) { - invalidFields.push({ field: 'provider_filter', reason: 'must not exceed 10 items' }); - } else if (!body.provider_filter.every((item: any) => typeof item === 'string')) { - invalidFields.push({ field: 'provider_filter', reason: 'all items must be strings' }); - } - } - // Validate lang field if provided if (body.lang !== undefined && !['en', 'zh', 'ja', 'ko'].includes(body.lang)) { invalidFields.push({ field: 'lang', reason: "must be one of: 'en', 'zh', 'ja', 'ko'" }); @@ -684,6 +670,74 @@ export function sanitizeForAIPrompt(input: string, maxLength: number = 200): str return sanitized.slice(0, maxLength); } +/** + * Exchange rate constants + */ +const EXCHANGE_RATE_CACHE_KEY = 'exchange_rate:USD_KRW'; +const EXCHANGE_RATE_TTL_SECONDS = 3600; // 1 hour +const EXCHANGE_RATE_FALLBACK = 1450; // Fallback KRW rate if API fails + +/** + * Get USD to KRW exchange rate with KV caching + * Uses open.er-api.com free API + */ +export async function getExchangeRate(env: Env): Promise { + // Try to get cached rate from KV + if (env.CACHE) { + try { + const cached = await env.CACHE.get(EXCHANGE_RATE_CACHE_KEY); + if (cached) { + const data = JSON.parse(cached) as ExchangeRateCache; + console.log(`[ExchangeRate] Using cached rate: ${data.rate}`); + return data.rate; + } + } catch (error) { + console.warn('[ExchangeRate] Cache read error:', error); + } + } + + // Fetch fresh rate from API + try { + const response = await fetch('https://open.er-api.com/v6/latest/USD', { + headers: { 'Accept': 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`API returned ${response.status}`); + } + + const data = await response.json() as { rates?: { KRW?: number } }; + const rate = data?.rates?.KRW; + + if (!rate || typeof rate !== 'number' || rate < 1000 || rate > 2000) { + console.warn('[ExchangeRate] Invalid rate from API:', rate); + return EXCHANGE_RATE_FALLBACK; + } + + console.log(`[ExchangeRate] Fetched fresh rate: ${rate}`); + + // Cache the rate + if (env.CACHE) { + try { + const cacheData: ExchangeRateCache = { + rate, + timestamp: Date.now(), + }; + await env.CACHE.put(EXCHANGE_RATE_CACHE_KEY, JSON.stringify(cacheData), { + expirationTtl: EXCHANGE_RATE_TTL_SECONDS, + }); + } catch (error) { + console.warn('[ExchangeRate] Cache write error:', error); + } + } + + return rate; + } catch (error) { + console.error('[ExchangeRate] API error:', error); + return EXCHANGE_RATE_FALLBACK; + } +} + // In-memory fallback for rate limiting when CACHE KV is unavailable const inMemoryRateLimit = new Map();