feat: add region diversity, HTML report, and transfer pricing
Region Diversity: - No region specified → same spec from 3 different regions - Cache key now includes region_preference - Fixed server_id to use ap.id (pricing) instead of ai.id (instance) HTML Report: - New /api/recommend/report endpoint for printable reports - Supports multi-language (en, ko, ja, zh) - Displays bandwidth_info with proper KRW formatting Transfer Pricing: - bandwidth_info includes overage costs from anvil_transfer_pricing - available_regions shows alternative regions with prices Code Quality: - Extracted region-utils.ts for flexible region matching - Cleaned up AI prompt (removed obsolete provider references) - Renamed project to cloud-orchestrator Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,7 @@ export const USE_CASE_CONFIGS: UseCaseConfig[] = [
|
||||
export const i18n: Record<string, {
|
||||
missingFields: string;
|
||||
invalidFields: string;
|
||||
techStackItemLength: string;
|
||||
schema: Record<string, string>;
|
||||
example: Record<string, any>;
|
||||
aiLanguageInstruction: string;
|
||||
@@ -83,6 +84,7 @@ export const i18n: Record<string, {
|
||||
en: {
|
||||
missingFields: 'Missing required fields',
|
||||
invalidFields: 'Invalid field values',
|
||||
techStackItemLength: 'all items must be strings with max 50 characters',
|
||||
schema: {
|
||||
tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']",
|
||||
expected_users: "(required) number - expected concurrent users, e.g. 1000",
|
||||
@@ -102,6 +104,7 @@ export const i18n: Record<string, {
|
||||
zh: {
|
||||
missingFields: '缺少必填字段',
|
||||
invalidFields: '字段值无效',
|
||||
techStackItemLength: '所有项目必须是最长50个字符的字符串',
|
||||
schema: {
|
||||
tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']",
|
||||
expected_users: "(必填) number - 预计同时在线用户数,例如 1000",
|
||||
@@ -121,6 +124,7 @@ export const i18n: Record<string, {
|
||||
ja: {
|
||||
missingFields: '必須フィールドがありません',
|
||||
invalidFields: 'フィールド値が無効です',
|
||||
techStackItemLength: 'すべての項目は最大50文字の文字列でなければなりません',
|
||||
schema: {
|
||||
tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']",
|
||||
expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000",
|
||||
@@ -140,6 +144,7 @@ export const i18n: Record<string, {
|
||||
ko: {
|
||||
missingFields: '필수 필드가 누락되었습니다',
|
||||
invalidFields: '필드 값이 잘못되었습니다',
|
||||
techStackItemLength: '모든 항목은 50자 이하의 문자열이어야 합니다',
|
||||
schema: {
|
||||
tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']",
|
||||
expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000",
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
generateCacheKey,
|
||||
estimateBandwidth,
|
||||
calculateBandwidthInfo,
|
||||
escapeLikePattern,
|
||||
isValidServer,
|
||||
isValidBenchmarkData,
|
||||
isValidVPSBenchmark,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
sanitizeForAIPrompt,
|
||||
getExchangeRate
|
||||
} from '../utils';
|
||||
import { escapeLikePattern, buildFlexibleRegionConditionsAnvil } from '../region-utils';
|
||||
|
||||
export async function handleRecommend(
|
||||
request: Request,
|
||||
@@ -62,7 +62,26 @@ export async function handleRecommend(
|
||||
);
|
||||
}
|
||||
|
||||
const body = JSON.parse(bodyText) as RecommendRequest;
|
||||
// Parse JSON with explicit error handling
|
||||
let body: RecommendRequest;
|
||||
try {
|
||||
body = JSON.parse(bodyText) as RecommendRequest;
|
||||
} catch (parseError) {
|
||||
console.error('[Recommend] JSON parse error:', parseError instanceof Error ? parseError.message : 'Unknown');
|
||||
return jsonResponse({
|
||||
error: 'Invalid JSON format',
|
||||
request_id: requestId,
|
||||
}, 400, corsHeaders);
|
||||
}
|
||||
|
||||
// Validate body is an object before proceeding
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return jsonResponse({
|
||||
error: body && 'lang' in body && body.lang === 'ko' ? '요청 본문은 객체여야 합니다' : 'Request body must be an object',
|
||||
request_id: requestId,
|
||||
}, 400, corsHeaders);
|
||||
}
|
||||
|
||||
const lang = body.lang || 'en';
|
||||
const validationError = validateRecommendRequest(body, lang);
|
||||
if (validationError) {
|
||||
@@ -74,7 +93,6 @@ export async function handleRecommend(
|
||||
expected_users: body.expected_users,
|
||||
use_case_length: body.use_case.length,
|
||||
traffic_pattern: body.traffic_pattern,
|
||||
has_region_pref: !!body.region_preference,
|
||||
has_budget: !!body.budget_limit,
|
||||
lang: lang,
|
||||
});
|
||||
@@ -227,16 +245,27 @@ export async function handleRecommend(
|
||||
const estimatedMemory = minMemoryMb ? Math.ceil(minMemoryMb / 1024) : 4;
|
||||
const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr'];
|
||||
|
||||
// Phase 2: Query candidate servers and VPS benchmarks in parallel
|
||||
const [candidates, vpsBenchmarks] = await Promise.all([
|
||||
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang),
|
||||
// Phase 2: Parallel queries including exchange rate for Korean users
|
||||
const exchangeRatePromise = lang === 'ko' ? getExchangeRate(env) : Promise.resolve(1);
|
||||
|
||||
const [candidates, vpsBenchmarks, exchangeRate] = await Promise.all([
|
||||
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang, 1), // Pass temporary rate of 1
|
||||
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
|
||||
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<Server[]> {
|
||||
// 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<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
|
||||
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<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
|
||||
*/
|
||||
@@ -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<string, Server>();
|
||||
for (const server of candidates) {
|
||||
const region = server.region_name;
|
||||
const existing = bestByRegion.get(region);
|
||||
// Keep the cheapest server that meets requirements for each region
|
||||
if (!existing || server.monthly_price < existing.monthly_price) {
|
||||
bestByRegion.set(region, server);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by price
|
||||
topCandidates = Array.from(bestByRegion.values())
|
||||
.sort((a, b) => a.monthly_price - b.monthly_price);
|
||||
|
||||
console.log(`[AI] Region diversity FORCED: ${topCandidates.length} regions, 1 server each`);
|
||||
console.log(`[AI] Regions: ${topCandidates.map(s => s.region_name).join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`);
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
}
|
||||
|
||||
639
src/handlers/report.ts
Normal file
639
src/handlers/report.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* GET /api/recommend/report - Generate HTML report from recommendation results
|
||||
*
|
||||
* Query params:
|
||||
* - data: Base64-encoded JSON of recommendation response
|
||||
* - lang: Language (en, ko, ja, zh) - default: en
|
||||
*/
|
||||
|
||||
import type { Env, RecommendationResult, BandwidthEstimate } from '../types';
|
||||
import { jsonResponse } from '../utils';
|
||||
|
||||
interface ReportData {
|
||||
recommendations: RecommendationResult[];
|
||||
bandwidth_estimate: BandwidthEstimate & {
|
||||
calculation_note?: string;
|
||||
};
|
||||
total_candidates: number;
|
||||
request?: {
|
||||
tech_stack: string[];
|
||||
expected_users: number;
|
||||
use_case: string;
|
||||
};
|
||||
}
|
||||
|
||||
const i18n: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
title: 'Server Recommendation Report',
|
||||
subtitle: 'AI-Powered Infrastructure Analysis',
|
||||
requirements: 'Requirements',
|
||||
techStack: 'Tech Stack',
|
||||
expectedUsers: 'Expected Users',
|
||||
useCase: 'Use Case',
|
||||
bandwidthEstimate: 'Bandwidth Estimate',
|
||||
monthly: 'Monthly',
|
||||
daily: 'Daily',
|
||||
category: 'Category',
|
||||
recommendations: 'Recommendations',
|
||||
budget: 'Budget',
|
||||
balanced: 'Balanced',
|
||||
premium: 'Premium',
|
||||
specs: 'Specifications',
|
||||
vcpu: 'vCPU',
|
||||
memory: 'Memory',
|
||||
storage: 'Storage',
|
||||
region: 'Region',
|
||||
price: 'Price',
|
||||
score: 'Score',
|
||||
analysis: 'Analysis',
|
||||
techFit: 'Tech Fit',
|
||||
capacity: 'Capacity',
|
||||
costEfficiency: 'Cost Efficiency',
|
||||
scalability: 'Scalability',
|
||||
bandwidthInfo: 'Bandwidth Cost',
|
||||
includedTransfer: 'Included Transfer',
|
||||
overagePrice: 'Overage Price',
|
||||
estimatedUsage: 'Estimated Usage',
|
||||
estimatedOverage: 'Estimated Overage',
|
||||
estimatedCost: 'Est. Overage Cost',
|
||||
totalCost: 'Total Est. Cost',
|
||||
warning: 'Warning',
|
||||
generatedAt: 'Generated at',
|
||||
poweredBy: 'Powered by Cloud Orchestrator',
|
||||
printNote: 'Print this page or save as PDF using your browser',
|
||||
perMonth: '/month',
|
||||
perGb: '/GB',
|
||||
},
|
||||
ko: {
|
||||
title: '서버 추천 보고서',
|
||||
subtitle: 'AI 기반 인프라 분석',
|
||||
requirements: '요구사항',
|
||||
techStack: '기술 스택',
|
||||
expectedUsers: '예상 사용자',
|
||||
useCase: '용도',
|
||||
bandwidthEstimate: '대역폭 예측',
|
||||
monthly: '월간',
|
||||
daily: '일간',
|
||||
category: '카테고리',
|
||||
recommendations: '추천 서버',
|
||||
budget: '예산형',
|
||||
balanced: '균형형',
|
||||
premium: '프리미엄',
|
||||
specs: '사양',
|
||||
vcpu: 'vCPU',
|
||||
memory: '메모리',
|
||||
storage: '스토리지',
|
||||
region: '리전',
|
||||
price: '가격',
|
||||
score: '점수',
|
||||
analysis: '분석',
|
||||
techFit: '기술 적합성',
|
||||
capacity: '용량',
|
||||
costEfficiency: '비용 효율성',
|
||||
scalability: '확장성',
|
||||
bandwidthInfo: '대역폭 비용',
|
||||
includedTransfer: '기본 제공',
|
||||
overagePrice: '초과 요금',
|
||||
estimatedUsage: '예상 사용량',
|
||||
estimatedOverage: '예상 초과량',
|
||||
estimatedCost: '예상 초과 비용',
|
||||
totalCost: '총 예상 비용',
|
||||
warning: '주의',
|
||||
generatedAt: '생성 시각',
|
||||
poweredBy: 'Cloud Orchestrator 제공',
|
||||
printNote: '브라우저에서 인쇄하거나 PDF로 저장하세요',
|
||||
perMonth: '/월',
|
||||
perGb: '/GB',
|
||||
},
|
||||
};
|
||||
|
||||
function getLabels(lang: string): Record<string, string> {
|
||||
return i18n[lang] || i18n['en'];
|
||||
}
|
||||
|
||||
function formatPrice(price: number, currency: string): string {
|
||||
if (currency === 'KRW') {
|
||||
return `₩${Math.round(price).toLocaleString()}`;
|
||||
}
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function getTierLabel(index: number, labels: Record<string, string>): string {
|
||||
const tiers = [labels.budget, labels.balanced, labels.premium];
|
||||
return tiers[index] || `Option ${index + 1}`;
|
||||
}
|
||||
|
||||
function getTierColor(index: number): string {
|
||||
const colors = ['#10b981', '#3b82f6', '#8b5cf6']; // green, blue, purple
|
||||
return colors[index] || '#6b7280';
|
||||
}
|
||||
|
||||
export async function handleReport(
|
||||
request: Request,
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const dataParam = url.searchParams.get('data');
|
||||
const lang = url.searchParams.get('lang') || 'en';
|
||||
|
||||
if (!dataParam) {
|
||||
return jsonResponse(
|
||||
{ error: 'Missing data parameter. Provide Base64-encoded recommendation data.' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
// Decode Base64 data
|
||||
let reportData: ReportData;
|
||||
try {
|
||||
const decoded = atob(dataParam);
|
||||
reportData = JSON.parse(decoded) as ReportData;
|
||||
} catch {
|
||||
return jsonResponse(
|
||||
{ error: 'Invalid data parameter. Must be valid Base64-encoded JSON.' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
if (!reportData.recommendations || reportData.recommendations.length === 0) {
|
||||
return jsonResponse(
|
||||
{ error: 'No recommendations in data.' },
|
||||
400,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
const labels = getLabels(lang);
|
||||
const html = generateReportHTML(reportData, labels, lang);
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Report] Error:', error);
|
||||
return jsonResponse(
|
||||
{ error: 'Failed to generate report' },
|
||||
500,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateReportHTML(
|
||||
data: ReportData,
|
||||
labels: Record<string, string>,
|
||||
lang: string
|
||||
): string {
|
||||
const { recommendations, bandwidth_estimate, request } = data;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const recommendationCards = recommendations.map((rec, index) => {
|
||||
const { server, score, analysis, bandwidth_info } = rec;
|
||||
const tierLabel = getTierLabel(index, labels);
|
||||
const tierColor = getTierColor(index);
|
||||
const currency = server.currency || 'USD';
|
||||
|
||||
return `
|
||||
<div class="recommendation-card" style="border-left-color: ${tierColor}">
|
||||
<div class="card-header">
|
||||
<div class="tier-badge" style="background-color: ${tierColor}">${tierLabel}</div>
|
||||
<div class="score">${labels.score}: ${score}/100</div>
|
||||
</div>
|
||||
|
||||
<h3 class="server-name">${server.provider_name} - ${server.instance_name}</h3>
|
||||
|
||||
<div class="specs-grid">
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">${labels.vcpu}</span>
|
||||
<span class="spec-value">${server.vcpu} cores</span>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">${labels.memory}</span>
|
||||
<span class="spec-value">${server.memory_gb} GB</span>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">${labels.storage}</span>
|
||||
<span class="spec-value">${server.storage_gb} GB</span>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">${labels.region}</span>
|
||||
<span class="spec-value">${server.region_name}</span>
|
||||
</div>
|
||||
<div class="spec-item highlight">
|
||||
<span class="spec-label">${labels.price}</span>
|
||||
<span class="spec-value">${formatPrice(server.monthly_price, currency)}${labels.perMonth}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${bandwidth_info ? `
|
||||
<div class="bandwidth-section">
|
||||
<h4>${labels.bandwidthInfo}</h4>
|
||||
<div class="bandwidth-grid">
|
||||
<div class="bandwidth-item">
|
||||
<span class="bw-label">${labels.includedTransfer}</span>
|
||||
<span class="bw-value">${bandwidth_info.included_transfer_tb} TB${labels.perMonth}</span>
|
||||
</div>
|
||||
<div class="bandwidth-item">
|
||||
<span class="bw-label">${labels.overagePrice}</span>
|
||||
<span class="bw-value">${bandwidth_info.currency === 'KRW' ? `₩${bandwidth_info.overage_cost_per_gb}` : `$${bandwidth_info.overage_cost_per_gb.toFixed(4)}`}${labels.perGb}</span>
|
||||
</div>
|
||||
<div class="bandwidth-item">
|
||||
<span class="bw-label">${labels.estimatedUsage}</span>
|
||||
<span class="bw-value">${bandwidth_info.estimated_monthly_tb} TB</span>
|
||||
</div>
|
||||
${bandwidth_info.estimated_overage_tb > 0 ? `
|
||||
<div class="bandwidth-item warning">
|
||||
<span class="bw-label">${labels.estimatedOverage}</span>
|
||||
<span class="bw-value">${bandwidth_info.estimated_overage_tb} TB</span>
|
||||
</div>
|
||||
<div class="bandwidth-item warning">
|
||||
<span class="bw-label">${labels.estimatedCost}</span>
|
||||
<span class="bw-value">+${bandwidth_info.currency === 'KRW' ? `₩${Math.round(bandwidth_info.estimated_overage_cost).toLocaleString()}` : `$${bandwidth_info.estimated_overage_cost.toFixed(2)}`}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="bandwidth-item total">
|
||||
<span class="bw-label">${labels.totalCost}</span>
|
||||
<span class="bw-value">${bandwidth_info.currency === 'KRW' ? `₩${Math.round(bandwidth_info.total_estimated_cost).toLocaleString()}` : `$${bandwidth_info.total_estimated_cost.toFixed(2)}`}${labels.perMonth}</span>
|
||||
</div>
|
||||
</div>
|
||||
${bandwidth_info.warning ? `<div class="bandwidth-warning">${bandwidth_info.warning}</div>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="analysis-section">
|
||||
<h4>${labels.analysis}</h4>
|
||||
<div class="analysis-item">
|
||||
<strong>${labels.techFit}:</strong> ${analysis.tech_fit}
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<strong>${labels.capacity}:</strong> ${analysis.capacity}
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<strong>${labels.costEfficiency}:</strong> ${analysis.cost_efficiency}
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<strong>${labels.scalability}:</strong> ${analysis.scalability}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${lang}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${labels.title}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
background: #f9fafb;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.print-note {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.requirements-section {
|
||||
background: #f3f4f6;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.requirements-section h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.req-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.req-item {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.req-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.req-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.bandwidth-estimate {
|
||||
background: #eff6ff;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.bandwidth-estimate h3 {
|
||||
font-size: 0.875rem;
|
||||
color: #1e40af;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bw-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bw-stat {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bw-stat strong {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
h2.section-title {
|
||||
font-size: 1.5rem;
|
||||
color: #111827;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.recommendation-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left: 4px solid;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tier-badge {
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 1.25rem;
|
||||
color: #111827;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.specs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
background: #f9fafb;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.spec-item.highlight {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.spec-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spec-value {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.bandwidth-section {
|
||||
background: #fefce8;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bandwidth-section h4 {
|
||||
font-size: 0.875rem;
|
||||
color: #854d0e;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.bandwidth-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bandwidth-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.bandwidth-item.warning {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.bandwidth-item.total {
|
||||
font-weight: 600;
|
||||
border-top: 1px solid #fde68a;
|
||||
padding-top: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.bw-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.bw-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bandwidth-warning {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.analysis-section h4 {
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-item {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.analysis-item strong {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
box-shadow: none;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.print-note {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recommendation-card {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>${labels.title}</h1>
|
||||
<p class="subtitle">${labels.subtitle}</p>
|
||||
</header>
|
||||
|
||||
<div class="print-note">
|
||||
🖨️ ${labels.printNote}
|
||||
</div>
|
||||
|
||||
${request ? `
|
||||
<section class="requirements-section">
|
||||
<h2>${labels.requirements}</h2>
|
||||
<div class="req-grid">
|
||||
<div class="req-item">
|
||||
<span class="req-label">${labels.techStack}</span>
|
||||
<span class="req-value">${request.tech_stack.join(', ')}</span>
|
||||
</div>
|
||||
<div class="req-item">
|
||||
<span class="req-label">${labels.expectedUsers}</span>
|
||||
<span class="req-value">${request.expected_users.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="req-item">
|
||||
<span class="req-label">${labels.useCase}</span>
|
||||
<span class="req-value">${request.use_case}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${bandwidth_estimate ? `
|
||||
<div class="bandwidth-estimate">
|
||||
<h3>${labels.bandwidthEstimate}</h3>
|
||||
<div class="bw-stats">
|
||||
<span class="bw-stat"><strong>${labels.monthly}:</strong> ${bandwidth_estimate.monthly_tb >= 1 ? `${bandwidth_estimate.monthly_tb} TB` : `${bandwidth_estimate.monthly_gb} GB`}</span>
|
||||
<span class="bw-stat"><strong>${labels.daily}:</strong> ${bandwidth_estimate.daily_gb} GB</span>
|
||||
<span class="bw-stat"><strong>${labels.category}:</strong> ${bandwidth_estimate.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</section>
|
||||
` : ''}
|
||||
|
||||
<section>
|
||||
<h2 class="section-title">${labels.recommendations}</h2>
|
||||
${recommendationCards}
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>${labels.generatedAt}: ${now}</p>
|
||||
<p>${labels.poweredBy}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -4,85 +4,10 @@
|
||||
|
||||
import type { Env } from '../types';
|
||||
import { 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 };
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
137
src/region-utils.ts
Normal file
137
src/region-utils.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Region-related utilities for flexible region matching
|
||||
* Consolidates duplicated region code from utils.ts, recommend.ts, and servers.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Country name to region code/city mapping for flexible region matching
|
||||
* Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi')
|
||||
*/
|
||||
export const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
|
||||
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
||||
'singapore': ['singapore', 'ap-southeast-1'],
|
||||
'indonesia': ['jakarta', 'ap-southeast-3'],
|
||||
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
|
||||
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
|
||||
'usa': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'us': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'united states': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'uk': ['london', 'uk-south', 'eu-west-2'],
|
||||
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
|
||||
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
|
||||
'france': ['paris', 'eu-west-3'],
|
||||
'netherlands': ['amsterdam', 'eu-west-1'],
|
||||
'brazil': ['sao paulo', 'sa-east'],
|
||||
'canada': ['toronto', 'montreal', 'ca-central'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape LIKE pattern special characters
|
||||
*/
|
||||
export function escapeLikePattern(pattern: string): string {
|
||||
return pattern.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Default region filter SQL for legacy regions table (when no region is specified)
|
||||
* Used in /api/recommend for backward compatibility
|
||||
*/
|
||||
export const DEFAULT_REGION_FILTER_SQL = `(
|
||||
-- Korea (Seoul)
|
||||
r.region_code IN ('icn', 'ap-northeast-2') OR
|
||||
LOWER(r.region_name) LIKE '%seoul%' OR
|
||||
-- Japan (Tokyo, Osaka)
|
||||
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
||||
LOWER(r.region_code) LIKE '%tyo%' OR
|
||||
LOWER(r.region_code) LIKE '%osa%' OR
|
||||
LOWER(r.region_name) LIKE '%tokyo%' OR
|
||||
LOWER(r.region_name) LIKE '%osaka%' OR
|
||||
-- Singapore
|
||||
r.region_code IN ('sgp', 'ap-southeast-1') OR
|
||||
LOWER(r.region_code) LIKE '%sin%' OR
|
||||
LOWER(r.region_code) LIKE '%sgp%' OR
|
||||
LOWER(r.region_name) LIKE '%singapore%'
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Default region filter SQL for anvil_regions (when no region is specified)
|
||||
* Used in both /api/recommend and /api/servers
|
||||
*/
|
||||
export const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
|
||||
-- Korea (Seoul)
|
||||
ar.name IN ('icn', 'ap-northeast-2') OR
|
||||
LOWER(ar.display_name) LIKE '%seoul%' OR
|
||||
-- Japan (Tokyo, Osaka)
|
||||
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
||||
LOWER(ar.name) LIKE '%tyo%' OR
|
||||
LOWER(ar.name) LIKE '%osa%' OR
|
||||
LOWER(ar.display_name) LIKE '%tokyo%' OR
|
||||
LOWER(ar.display_name) LIKE '%osaka%' OR
|
||||
-- Singapore
|
||||
ar.name IN ('sgp', 'ap-southeast-1') OR
|
||||
LOWER(ar.name) LIKE '%sin%' OR
|
||||
LOWER(ar.name) LIKE '%sgp%' OR
|
||||
LOWER(ar.display_name) LIKE '%singapore%'
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Build flexible region matching SQL conditions for legacy regions table
|
||||
* Returns SQL conditions and parameters for use in prepared statements
|
||||
*/
|
||||
export function buildFlexibleRegionConditions(
|
||||
regions: string[]
|
||||
): { conditions: string[]; params: (string | number)[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
for (const region of regions) {
|
||||
const lowerRegion = region.toLowerCase();
|
||||
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
||||
const allRegions = [lowerRegion, ...expandedRegions];
|
||||
|
||||
for (const r of allRegions) {
|
||||
const escapedRegion = escapeLikePattern(r);
|
||||
conditions.push(`(
|
||||
LOWER(r.region_code) = ? OR
|
||||
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(r.country_code) = ?
|
||||
)`);
|
||||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
||||
}
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build flexible region matching SQL conditions for anvil_regions
|
||||
* Returns SQL conditions and parameters for use in prepared statements
|
||||
*/
|
||||
export function buildFlexibleRegionConditionsAnvil(
|
||||
regions: string[]
|
||||
): { conditions: string[]; params: (string | number)[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
for (const region of regions) {
|
||||
const lowerRegion = region.toLowerCase();
|
||||
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
||||
const allRegions = [lowerRegion, ...expandedRegions];
|
||||
|
||||
for (const r of allRegions) {
|
||||
const escapedRegion = escapeLikePattern(r);
|
||||
conditions.push(`(
|
||||
LOWER(ar.name) = ? OR
|
||||
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(ar.country_code) = ?
|
||||
)`);
|
||||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
||||
}
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
}
|
||||
10
src/types.ts
10
src/types.ts
@@ -50,16 +50,20 @@ export interface Server {
|
||||
currency: 'USD' | 'KRW';
|
||||
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; // 트래픽 관련 경고
|
||||
}
|
||||
|
||||
|
||||
238
src/utils.ts
238
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<string, string[]> = {
|
||||
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
||||
'singapore': ['singapore', 'ap-southeast-1'],
|
||||
'indonesia': ['jakarta', 'ap-southeast-3'],
|
||||
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
|
||||
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
|
||||
'usa': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'us': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'united states': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'uk': ['london', 'uk-south', 'eu-west-2'],
|
||||
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
|
||||
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
|
||||
'france': ['paris', 'eu-west-3'],
|
||||
'netherlands': ['amsterdam', 'eu-west-1'],
|
||||
'brazil': ['sao paulo', 'sa-east'],
|
||||
'canada': ['toronto', 'montreal', 'ca-central'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build flexible region matching SQL conditions and parameters
|
||||
*/
|
||||
export function buildFlexibleRegionConditions(
|
||||
regions: string[]
|
||||
): { conditions: string[]; params: (string | number)[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
for (const region of regions) {
|
||||
const lowerRegion = region.toLowerCase();
|
||||
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
||||
const allRegions = [lowerRegion, ...expandedRegions];
|
||||
|
||||
for (const r of allRegions) {
|
||||
const escapedRegion = escapeLikePattern(r);
|
||||
conditions.push(`(
|
||||
LOWER(r.region_code) = ? OR
|
||||
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(r.country_code) = ?
|
||||
)`);
|
||||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
||||
}
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
}
|
||||
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<number> {
|
||||
|
||||
// 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<number> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user