feat: migrate pricing from legacy tables to anvil_pricing

- Replace pricing/instance_types/providers/regions with anvil_* tables
- Add real-time USD→KRW exchange rate conversion (open.er-api.com)
- Korean users (lang=ko) see KRW prices, others see USD
- Remove provider_filter parameter (now single provider: Anvil)
- Add ExchangeRateCache interface with 1-hour KV caching
- Update CLAUDE.md with new table structure and exchange rate docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-26 01:05:44 +09:00
parent f6c35067f9
commit 580cc1bbe2
6 changed files with 350 additions and 142 deletions

View File

@@ -90,7 +90,6 @@ export const i18n: Record<string, {
traffic_pattern: "(optional) 'steady' | 'spiky' | 'growing'",
region_preference: "(optional) string[] - e.g. ['korea', 'japan']",
budget_limit: "(optional) number - max monthly USD",
provider_filter: "(optional) string[] - e.g. ['linode', 'vultr']",
lang: "(optional) 'en' | 'zh' | 'ja' | 'ko' - response language"
},
example: {
@@ -110,7 +109,6 @@ export const i18n: Record<string, {
traffic_pattern: "(可选) 'steady' | 'spiky' | 'growing'",
region_preference: "(可选) string[] - 例如 ['korea', 'japan']",
budget_limit: "(可选) number - 每月最高预算(美元)",
provider_filter: "(可选) string[] - 例如 ['linode', 'vultr']",
lang: "(可选) 'en' | 'zh' | 'ja' | 'ko' - 响应语言"
},
example: {
@@ -130,7 +128,6 @@ export const i18n: Record<string, {
traffic_pattern: "(任意) 'steady' | 'spiky' | 'growing'",
region_preference: "(任意) string[] - 例: ['korea', 'japan']",
budget_limit: "(任意) number - 月額予算上限(USD)",
provider_filter: "(任意) string[] - 例: ['linode', 'vultr']",
lang: "(任意) 'en' | 'zh' | 'ja' | 'ko' - 応答言語"
},
example: {
@@ -150,7 +147,6 @@ export const i18n: Record<string, {
traffic_pattern: "(선택) 'steady' | 'spiky' | 'growing'",
region_preference: "(선택) string[] - 예: ['korea', 'japan']",
budget_limit: "(선택) number - 월 예산 한도(원화, KRW)",
provider_filter: "(선택) string[] - 예: ['linode', 'vultr']",
lang: "(선택) 'en' | 'zh' | 'ja' | 'ko' - 응답 언어"
},
example: {

View File

@@ -29,8 +29,7 @@ import {
isValidTechSpec,
isValidAIRecommendation,
sanitizeForAIPrompt,
DEFAULT_REGION_FILTER_SQL,
buildFlexibleRegionConditions
getExchangeRate
} from '../utils';
export async function handleRecommend(
@@ -77,7 +76,6 @@ export async function handleRecommend(
traffic_pattern: body.traffic_pattern,
has_region_pref: !!body.region_preference,
has_budget: !!body.budget_limit,
has_provider_filter: !!body.provider_filter,
lang: lang,
});
@@ -231,7 +229,7 @@ export async function handleRecommend(
// Phase 2: Query candidate servers and VPS benchmarks in parallel
const [candidates, vpsBenchmarks] = await Promise.all([
queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang),
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang),
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
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<Server[]> {
// 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<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 {
...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<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
*/

View File

@@ -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<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
* Uses anvil_* tables for pricing data
*/
export async function handleGetServers(
request: Request,
@@ -15,57 +95,49 @@ export async function handleGetServers(
): Promise<Response> {
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

View File

@@ -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;

View File

@@ -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<number> {
// 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<string, { count: number; resetTime: number }>();