Files
anvil-hosting/functions/api/pricing.ts
kappa d08d1895d0 refactor: 가격표 섹션 히어로 아래로 이동 및 탭 스타일 통일
- 가격표 섹션을 페이지 하단에서 히어로 바로 아래로 이동
- 상단 패딩 축소 (py-24 → pt-12 pb-24)
- 서브탭(서울/글로벌 타입) 스타일을 메인탭과 동일하게 통일
- Pages Functions API 프록시 추가 (functions/)
- wrangler.toml 및 TypeScript 설정 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:19:01 +09:00

206 lines
6.0 KiB
TypeScript

/**
* Pricing endpoint - Direct D1 query (no rate limiting)
* GET /api/pricing → D1 cloud-instances-db
*
* Supported regions:
* - Tokyo, Osaka, Singapore: Linode
* - Seoul: Vultr
*/
import { type PagesFunction } from '@cloudflare/workers-types';
import { Env, createCorsPreflightResponse } from '../_shared/proxy';
interface InstanceRow {
instance_id: string;
instance_name: string;
vcpu: number;
memory_mb: number;
storage_gb: number | null;
transfer_tb: number | null;
provider_name: string;
region_name: string;
region_code: string;
monthly_price: number;
hourly_price: number | null;
monthly_price_krw: number | null;
hourly_price_krw: number | null;
// GPU fields
has_gpu?: number;
gpu_count?: number;
gpu_type?: string;
}
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
try {
// 1. 일반 인스턴스 쿼리 - Linode(Tokyo, Osaka, Singapore) + Vultr(Seoul)
const regularSql = `
SELECT
it.instance_id,
it.instance_name,
it.vcpu,
it.memory_mb,
it.storage_gb,
it.transfer_tb,
p.name as provider_name,
r.region_name,
r.region_code,
pr.monthly_price,
pr.hourly_price,
pr.monthly_price_krw,
pr.hourly_price_krw,
0 as has_gpu,
0 as gpu_count,
NULL as gpu_type
FROM instance_types it
JOIN providers p ON it.provider_id = p.id
JOIN pricing pr ON it.id = pr.instance_type_id
JOIN regions r ON pr.region_id = r.id
WHERE pr.available = 1
AND it.instance_id NOT LIKE '%-sc1'
AND (
(p.name = 'linode' AND (
r.region_name LIKE '%Tokyo%' OR
r.region_name LIKE '%Osaka%' OR
r.region_code LIKE '%jp-osa%' OR
r.region_name LIKE '%Singapore%'
))
OR
(p.name = 'vultr' AND (
r.region_name LIKE '%Seoul%' OR
r.region_code LIKE '%icn%'
))
)
ORDER BY pr.monthly_price ASC
`;
// 2. GPU 인스턴스 쿼리 - Linode(Tokyo) + Vultr(Seoul)
const gpuSql = `
SELECT
gi.instance_id,
gi.instance_name,
gi.vcpu,
gi.memory_mb,
gi.storage_gb,
gi.transfer_tb,
p.name as provider_name,
r.region_name,
r.region_code,
gp.monthly_price,
gp.hourly_price,
gp.monthly_price_krw,
gp.hourly_price_krw,
1 as has_gpu,
gi.gpu_count,
gi.gpu_type
FROM gpu_instances gi
JOIN providers p ON gi.provider_id = p.id
JOIN gpu_pricing gp ON gi.id = gp.gpu_instance_id
JOIN regions r ON gp.region_id = r.id
WHERE gp.available = 1
AND (
(p.name = 'linode' AND (
r.region_name LIKE '%Tokyo%' OR
r.region_name LIKE '%Osaka%' OR
r.region_code LIKE '%jp-osa%'
))
OR
(p.name = 'vultr' AND (
r.region_name LIKE '%Seoul%' OR
r.region_code LIKE '%icn%'
))
)
ORDER BY gp.monthly_price ASC
`;
const [regularResult, gpuResult] = await Promise.all([
env.DB.prepare(regularSql).all<InstanceRow>(),
env.DB.prepare(gpuSql).all<InstanceRow>(),
]);
// Transform to frontend format
const transformRow = (row: InstanceRow) => ({
id: row.instance_id,
instance_name: row.instance_name,
vcpu: row.vcpu,
memory_mb: row.memory_mb,
storage_gb: row.storage_gb,
transfer_tb: row.transfer_tb,
provider: { name: row.provider_name },
region: {
region_name: row.region_name,
region_code: row.region_code
},
pricing: {
monthly_price: row.monthly_price,
hourly_price: row.hourly_price,
monthly_price_krw: row.monthly_price_krw,
hourly_price_krw: row.hourly_price_krw,
},
has_gpu: row.has_gpu === 1,
gpu_count: row.gpu_count || 0,
gpu_type: row.gpu_type || null,
});
const regularInstances = regularResult.results.map(transformRow);
const gpuInstances = gpuResult.results.map(transformRow);
const instances = [...regularInstances, ...gpuInstances];
// Count by region
const regionCounts: Record<string, number> = {};
for (const inst of instances) {
const name = inst.region.region_name.toLowerCase();
if (name.includes('tokyo')) regionCounts['tokyo'] = (regionCounts['tokyo'] || 0) + 1;
else if (name.includes('osaka')) regionCounts['osaka'] = (regionCounts['osaka'] || 0) + 1;
else if (name.includes('singapore')) regionCounts['singapore'] = (regionCounts['singapore'] || 0) + 1;
else if (name.includes('seoul')) regionCounts['seoul'] = (regionCounts['seoul'] || 0) + 1;
}
// GPU counts
const gpuCounts = {
'gpu-japan': gpuInstances.filter(i =>
i.region.region_name.toLowerCase().includes('tokyo') ||
i.region.region_name.toLowerCase().includes('osaka')
).length,
'gpu-korea': gpuInstances.filter(i =>
i.region.region_name.toLowerCase().includes('seoul')
).length,
};
return new Response(JSON.stringify({
success: true,
total: instances.length,
region_counts: regionCounts,
gpu_counts: gpuCounts,
instances,
}), {
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300', // 5분 캐시
},
});
} catch (error) {
console.error('[Pricing] D1 query error:', error);
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Database query failed',
}), {
status: 500,
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
},
});
}
};
export const onRequestOptions: PagesFunction<Env> = async () => {
return createCorsPreflightResponse();
};