/** * Pricing endpoint - Anvil 가격표 (D1 직접 조회) * GET /api/pricing → D1 cloud-instances-db (anvil_* 테이블) * * Anvil Regions: * - Tokyo 1-3 (Linode × 2 + Vultr) * - Osaka 1-2 (Linode + Vultr) * - Seoul 1 (Vultr) * - Singapore 1 (Linode) */ import { type PagesFunction } from '@cloudflare/workers-types'; interface Env { DB: D1Database; } interface AnvilPricingRow { instance_id: number; instance_name: string; display_name: string; category: string; vcpus: number; memory_gb: number; disk_gb: number; transfer_tb: number | null; network_gbps: number | null; gpu_model: string | null; gpu_vram_gb: number | null; region_id: number; region_name: string; region_display_name: string; country_code: string; source_provider: string; hourly_price: number; monthly_price: number; } interface TransferPricingRow { region_name: string; region_display_name: string; price_per_gb: number; } // 기본 환율 (API 실패 시 fallback) const DEFAULT_USD_TO_KRW = 1450; // 실시간 환율 조회 (exchangerate-api.com) async function fetchExchangeRate(): Promise<{ rate: number; source: string }> { try { const response = await fetch('https://open.er-api.com/v6/latest/USD', { cf: { cacheTtl: 3600 }, // 1시간 캐시 }); if (!response.ok) { throw new Error(`Exchange rate API error: ${response.status}`); } const data = await response.json() as { rates?: { KRW?: number } }; const rate = data.rates?.KRW; if (rate && rate > 0) { return { rate: Math.round(rate), source: 'realtime' }; } throw new Error('Invalid exchange rate data'); } catch (error) { console.error('[Pricing] Exchange rate fetch failed:', error); return { rate: DEFAULT_USD_TO_KRW, source: 'fallback' }; } } const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; export const onRequestGet: PagesFunction = async ({ env }) => { try { // 실시간 환율 조회 const exchangeRate = await fetchExchangeRate(); const USD_TO_KRW = exchangeRate.rate; // Anvil 가격 조회 (인스턴스 + 리전 + 가격 조인) const pricingSql = ` SELECT ai.id as instance_id, ai.name as instance_name, ai.display_name, ai.category, ai.vcpus, ai.memory_gb, ai.disk_gb, ai.transfer_tb, ai.network_gbps, ai.gpu_model, ai.gpu_vram_gb, ar.id as region_id, ar.name as region_name, ar.display_name as region_display_name, ar.country_code, ar.source_provider, ap.hourly_price, ap.monthly_price FROM anvil_pricing ap JOIN anvil_instances ai 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 ORDER BY ar.id, ap.monthly_price ASC `; // 트래픽 가격 조회 const transferSql = ` SELECT ar.name as region_name, ar.display_name as region_display_name, atp.price_per_gb FROM anvil_transfer_pricing atp JOIN anvil_regions ar ON atp.anvil_region_id = ar.id WHERE ar.active = 1 `; const [pricingResult, transferResult] = await Promise.all([ env.DB.prepare(pricingSql).all(), env.DB.prepare(transferSql).all(), ]); // 인스턴스 변환 (프론트엔드 형식) const instances = pricingResult.results.map((row) => ({ id: row.instance_name, instance_name: row.instance_name, display_name: row.display_name, category: row.category, vcpu: row.vcpus, memory_mb: Math.round(row.memory_gb * 1024), memory_gb: row.memory_gb, storage_gb: row.disk_gb, transfer_tb: row.transfer_tb, network_gbps: row.network_gbps, gpu_model: row.gpu_model, gpu_vram_gb: row.gpu_vram_gb, has_gpu: row.category === 'gpu' || row.gpu_model !== null, region: { id: row.region_id, name: row.region_name, display_name: row.region_display_name, country_code: row.country_code, }, provider: row.source_provider, pricing: { hourly_price: row.hourly_price, monthly_price: row.monthly_price, hourly_price_krw: Math.round(row.hourly_price * USD_TO_KRW), // 1원 단위 반올림 monthly_price_krw: Math.round(row.monthly_price * USD_TO_KRW / 500) * 500, // 500원 단위 반올림 }, })); // 트래픽 가격 변환 (리전별) - 1원 단위 반올림 const transferPricing = transferResult.results.reduce((acc, row) => { acc[row.region_display_name] = { price_per_gb: row.price_per_gb, price_per_gb_krw: Math.round(row.price_per_gb * USD_TO_KRW), }; return acc; }, {} as Record); // 리전별 카운트 const regionCounts: Record = {}; for (const inst of instances) { const regionKey = inst.region.display_name.toLowerCase().replace(/\s+/g, '-'); regionCounts[regionKey] = (regionCounts[regionKey] || 0) + 1; } // 사용 가능한 리전 목록 (순서 유지: Tokyo 1,2,3 → Osaka 1,2 → Seoul 1 → Singapore 1) const seenRegions = new Set(); const regions = instances .filter((i) => { if (seenRegions.has(i.region.id)) return false; seenRegions.add(i.region.id); return true; }) .map((i) => ({ id: i.region.display_name.toLowerCase().replace(/\s+/g, '-'), name: i.region.display_name, country_code: i.region.country_code, count: regionCounts[i.region.display_name.toLowerCase().replace(/\s+/g, '-')] || 0, })); return new Response( JSON.stringify({ success: true, total: instances.length, exchange_rate: { usd_to_krw: USD_TO_KRW, source: exchangeRate.source, }, region_counts: regionCounts, regions, transfer_pricing: transferPricing, instances, }), { headers: { ...CORS_HEADERS, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300', // 5분 캐시 }, } ); } catch (error) { console.error('[Pricing] D1 query error:', error); return new Response( JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Database query failed', }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json', }, } ); } }; export const onRequestOptions: PagesFunction = async () => { return new Response(null, { status: 204, headers: CORS_HEADERS }); };