refactor: major architecture improvements and security hardening
## Security Fixes
- Fix XSS vulnerability in report.ts with escapeHtml()
- Add cache data integrity validation
- Add region_preference input validation (max 10 items, 50 chars each)
- Replace `any` types with `unknown` + type guards
## Architecture Refactoring
- Split utils.ts (801 lines) into 6 modules: http, validation, bandwidth, cache, ai, exchange-rate
- Extract AI logic to src/services/ai-service.ts (recommend.ts 49% reduction)
- Add Repository pattern: src/repositories/AnvilServerRepository.ts
- Reduce code duplication in DB queries
## New Features
- AI fallback: rule-based recommendations when OpenAI unavailable
- Vitest testing: 55 tests (utils.test.ts, bandwidth.test.ts)
- Duplicate server prevention in AI recommendations
## Files Added
- src/utils/{index,http,validation,bandwidth,cache,ai,exchange-rate}.ts
- src/services/ai-service.ts
- src/repositories/AnvilServerRepository.ts
- src/__tests__/{utils,bandwidth}.test.ts
- vitest.config.ts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
329
src/utils/bandwidth.ts
Normal file
329
src/utils/bandwidth.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Bandwidth estimation utility functions
|
||||
*/
|
||||
|
||||
import type { BandwidthEstimate, BandwidthInfo, UseCaseConfig } from '../types';
|
||||
import { USE_CASE_CONFIGS } from '../config';
|
||||
|
||||
/**
|
||||
* Find use case configuration by matching patterns
|
||||
*/
|
||||
export function findUseCaseConfig(useCase: string): UseCaseConfig {
|
||||
const useCaseLower = useCase.toLowerCase();
|
||||
|
||||
for (const config of USE_CASE_CONFIGS) {
|
||||
if (config.patterns.test(useCaseLower)) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
return {
|
||||
category: 'default',
|
||||
patterns: /.*/,
|
||||
dauMultiplier: { min: 10, max: 14 },
|
||||
activeRatio: 0.5
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DAU multiplier based on use case (how many daily active users per concurrent user)
|
||||
*/
|
||||
export function getDauMultiplier(useCase: string): { min: number; max: number } {
|
||||
return findUseCaseConfig(useCase).dauMultiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action)
|
||||
*/
|
||||
export function getActiveUserRatio(useCase: string): number {
|
||||
return findUseCaseConfig(useCase).activeRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate monthly bandwidth based on concurrent users and use case
|
||||
*/
|
||||
export function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate {
|
||||
const useCaseLower = useCase.toLowerCase();
|
||||
|
||||
// Get use case configuration
|
||||
const config = findUseCaseConfig(useCase);
|
||||
const useCaseCategory = config.category;
|
||||
|
||||
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
|
||||
const dauMultiplier = config.dauMultiplier;
|
||||
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
|
||||
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
|
||||
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
|
||||
const activeUserRatio = config.activeRatio;
|
||||
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
|
||||
|
||||
// Traffic pattern adjustment
|
||||
let patternMultiplier = 1.0;
|
||||
if (trafficPattern === 'spiky') {
|
||||
patternMultiplier = 1.5; // Account for peak loads
|
||||
} else if (trafficPattern === 'growing') {
|
||||
patternMultiplier = 1.3; // Headroom for growth
|
||||
}
|
||||
|
||||
let dailyBandwidthGB: number;
|
||||
let bandwidthModel: string;
|
||||
|
||||
// ========== IMPROVED BANDWIDTH MODELS ==========
|
||||
// Each use case uses the most appropriate calculation method
|
||||
|
||||
switch (useCaseCategory) {
|
||||
case 'video': {
|
||||
// VIDEO/STREAMING: Bitrate-based model
|
||||
const is4K = /4k|uhd|ultra/i.test(useCaseLower);
|
||||
const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD
|
||||
const avgWatchTimeHours = is4K ? 1.0 : 1.5;
|
||||
const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'file': {
|
||||
// FILE DOWNLOAD: File-size based model
|
||||
const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower);
|
||||
const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2;
|
||||
const downloadsPerUser = isLargeFiles ? 1 : 3;
|
||||
const gbPerActiveUser = avgFileSizeGB * downloadsPerUser;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gaming': {
|
||||
// GAMING: Session-duration based model
|
||||
const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower);
|
||||
const mbPerHour = isMinecraft ? 150 : 80;
|
||||
const avgSessionHours = isMinecraft ? 3 : 2.5;
|
||||
const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'api': {
|
||||
// API/SAAS: Request-based model
|
||||
const avgRequestKB = 20;
|
||||
const requestsPerUserPerDay = 1000;
|
||||
const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024);
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ecommerce': {
|
||||
// E-COMMERCE: Page-based model (images heavy)
|
||||
const avgPageSizeMB = 2.5;
|
||||
const pagesPerSession = 20;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'forum': {
|
||||
// FORUM/COMMUNITY: Page-based model (text + some images)
|
||||
const avgPageSizeMB = 0.7;
|
||||
const pagesPerSession = 30;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blog': {
|
||||
// STATIC/BLOG: Lightweight page-based model
|
||||
const avgPageSizeMB = 1.5;
|
||||
const pagesPerSession = 4;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chat': {
|
||||
// CHAT/MESSAGING: Message-based model
|
||||
const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages
|
||||
const attachmentBandwidthMB = 20; // occasional images/files
|
||||
const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// DEFAULT: General web app (page-based)
|
||||
const avgPageSizeMB = 1.0;
|
||||
const pagesPerSession = 10;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Bandwidth] Model: ${bandwidthModel}`);
|
||||
console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%), Daily: ${dailyBandwidthGB.toFixed(1)} GB`);
|
||||
|
||||
// Monthly bandwidth
|
||||
const monthlyGB = dailyBandwidthGB * 30;
|
||||
const monthlyTB = monthlyGB / 1024;
|
||||
|
||||
// Categorize
|
||||
let category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
|
||||
let description: string;
|
||||
|
||||
if (monthlyTB < 0.5) {
|
||||
category = 'light';
|
||||
description = `~${Math.round(monthlyGB)} GB/month - Most VPS plans include sufficient bandwidth`;
|
||||
} else if (monthlyTB < 2) {
|
||||
category = 'moderate';
|
||||
description = `~${monthlyTB.toFixed(1)} TB/month - Check provider bandwidth limits`;
|
||||
} else if (monthlyTB < 6) {
|
||||
category = 'heavy';
|
||||
description = `~${monthlyTB.toFixed(1)} TB/month - Prefer providers with generous bandwidth (Linode: 1-6TB included)`;
|
||||
} else {
|
||||
category = 'very_heavy';
|
||||
description = `~${monthlyTB.toFixed(1)} TB/month - HIGH BANDWIDTH: Linode strongly recommended for cost savings`;
|
||||
}
|
||||
|
||||
return {
|
||||
monthly_gb: Math.round(monthlyGB),
|
||||
monthly_tb: Math.round(monthlyTB * 10) / 10,
|
||||
daily_gb: Math.round(dailyBandwidthGB * 10) / 10,
|
||||
category,
|
||||
description,
|
||||
estimated_dau_min: estimatedDauMin,
|
||||
estimated_dau_max: estimatedDauMax,
|
||||
active_ratio: activeUserRatio
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider bandwidth allocation based on memory size
|
||||
* Returns included transfer in TB/month
|
||||
*/
|
||||
export function getProviderBandwidthAllocation(providerName: string, memoryGb: number): {
|
||||
included_tb: number;
|
||||
overage_per_gb: number;
|
||||
overage_per_tb: number;
|
||||
} {
|
||||
const provider = providerName.toLowerCase();
|
||||
|
||||
if (provider.includes('linode')) {
|
||||
// Linode: roughly 1TB per 1GB RAM (Nanode 1GB = 1TB, 2GB = 2TB, etc.)
|
||||
const includedTb = Math.min(Math.max(memoryGb, 1), 20);
|
||||
return {
|
||||
included_tb: includedTb,
|
||||
overage_per_gb: 0.005, // $0.005/GB = $5/TB
|
||||
overage_per_tb: 5
|
||||
};
|
||||
} else if (provider.includes('vultr')) {
|
||||
// Vultr: varies by plan, roughly 1-2TB for small, up to 10TB for large
|
||||
let includedTb: number;
|
||||
if (memoryGb <= 2) includedTb = 1;
|
||||
else if (memoryGb <= 4) includedTb = 2;
|
||||
else if (memoryGb <= 8) includedTb = 3;
|
||||
else if (memoryGb <= 16) includedTb = 4;
|
||||
else if (memoryGb <= 32) includedTb = 5;
|
||||
else includedTb = Math.min(memoryGb / 4, 10);
|
||||
|
||||
return {
|
||||
included_tb: includedTb,
|
||||
overage_per_gb: 0.01, // $0.01/GB = $10/TB
|
||||
overage_per_tb: 10
|
||||
};
|
||||
} else {
|
||||
// Default/Other providers: conservative estimate
|
||||
return {
|
||||
included_tb: Math.min(memoryGb, 5),
|
||||
overage_per_gb: 0.01,
|
||||
overage_per_tb: 10
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
lang: string = 'en',
|
||||
exchangeRate: number = 1
|
||||
): BandwidthInfo {
|
||||
// Use actual DB values if available (Anvil servers), fallback to provider-based estimation
|
||||
let includedTb: number;
|
||||
let overagePerGbUsd: number;
|
||||
let overagePerTbUsd: number;
|
||||
|
||||
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 / exchangeRate
|
||||
: server.monthly_price;
|
||||
|
||||
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 > includedTb) {
|
||||
const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
|
||||
warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
|
||||
} else if (overageTb > 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: 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: overageCost,
|
||||
total_estimated_cost: totalCost,
|
||||
currency,
|
||||
warning
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user