CASTAD-v0.1/server/billingService.js

310 lines
10 KiB
JavaScript

/**
* Google Cloud Billing Service
* BigQuery를 통해 실제 결제 데이터를 조회합니다.
*/
const { BigQuery } = require('@google-cloud/bigquery');
const path = require('path');
// 환경 변수
const KEY_PATH = process.env.GOOGLE_BILLING_KEY_PATH?.replace('./server/', path.join(__dirname, '/').replace('/server/', '/'));
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT_ID || 'grand-solstice-477822-s9';
const DATASET_ID = process.env.GOOGLE_BILLING_DATASET_ID || 'billing_export';
const BILLING_TABLE_PREFIX = 'gcp_billing_export';
let bigqueryClient = null;
/**
* BigQuery 클라이언트 초기화
*/
const getBigQueryClient = () => {
if (!bigqueryClient) {
const keyPath = KEY_PATH || path.join(__dirname, 'google-billing-key.json');
bigqueryClient = new BigQuery({
keyFilename: keyPath,
projectId: PROJECT_ID
});
}
return bigqueryClient;
};
/**
* 결제 테이블 이름 찾기
*/
const findBillingTable = async () => {
try {
const bigquery = getBigQueryClient();
const dataset = bigquery.dataset(DATASET_ID);
const [tables] = await dataset.getTables();
const billingTable = tables.find(t =>
t.id.includes(BILLING_TABLE_PREFIX) ||
t.id.includes('cloud_billing')
);
return billingTable ? billingTable.id : null;
} catch (error) {
console.error('[BillingService] 테이블 조회 오류:', error.message);
return null;
}
};
/**
* 서비스별 비용 요약 (최근 N일)
*/
const getCostByService = async (days = 30) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다. 24-48시간 후 다시 시도하세요.' };
}
const bigquery = getBigQueryClient();
const query = `
SELECT
service.description as service_name,
SUM(cost) as total_cost,
SUM(CASE WHEN cost > 0 THEN cost ELSE 0 END) as actual_cost,
currency,
COUNT(*) as usage_count
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${days} DAY)
GROUP BY service.description, currency
HAVING total_cost != 0
ORDER BY total_cost DESC
`;
const [rows] = await bigquery.query(query);
return {
period: `${days}`,
services: rows.map(row => ({
serviceName: row.service_name,
totalCost: parseFloat(row.total_cost?.toFixed(6) || 0),
actualCost: parseFloat(row.actual_cost?.toFixed(6) || 0),
currency: row.currency,
usageCount: row.usage_count
})),
totalCost: rows.reduce((sum, r) => sum + (r.actual_cost || 0), 0)
};
} catch (error) {
console.error('[BillingService] 서비스별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* 일별 비용 추이 (최근 N일)
*/
const getDailyCostTrend = async (days = 30) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다.' };
}
const bigquery = getBigQueryClient();
const query = `
SELECT
DATE(usage_start_time) as date,
SUM(cost) as total_cost,
currency
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${days} DAY)
GROUP BY DATE(usage_start_time), currency
ORDER BY date DESC
`;
const [rows] = await bigquery.query(query);
return {
period: `${days}`,
daily: rows.map(row => ({
date: row.date?.value || row.date,
cost: parseFloat(row.total_cost?.toFixed(6) || 0),
currency: row.currency
}))
};
} catch (error) {
console.error('[BillingService] 일별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* SKU별 상세 비용 (Gemini API 등)
*/
const getCostBySKU = async (days = 30, serviceFilter = null) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다.' };
}
const bigquery = getBigQueryClient();
let whereClause = `DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${days} DAY)`;
if (serviceFilter) {
whereClause += ` AND LOWER(service.description) LIKE '%${serviceFilter.toLowerCase()}%'`;
}
const query = `
SELECT
service.description as service_name,
sku.description as sku_name,
SUM(cost) as total_cost,
SUM(usage.amount) as total_usage,
usage.unit as usage_unit,
currency
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE ${whereClause}
GROUP BY service.description, sku.description, usage.unit, currency
HAVING total_cost > 0
ORDER BY total_cost DESC
LIMIT 50
`;
const [rows] = await bigquery.query(query);
return {
period: `${days}`,
filter: serviceFilter,
skus: rows.map(row => ({
serviceName: row.service_name,
skuName: row.sku_name,
totalCost: parseFloat(row.total_cost?.toFixed(6) || 0),
totalUsage: row.total_usage,
usageUnit: row.usage_unit,
currency: row.currency
}))
};
} catch (error) {
console.error('[BillingService] SKU별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* 월별 비용 요약
*/
const getMonthlyCost = async (months = 6) => {
try {
const tableName = await findBillingTable();
if (!tableName) {
return { error: '결제 데이터 테이블이 아직 생성되지 않았습니다.' };
}
const bigquery = getBigQueryClient();
const query = `
SELECT
FORMAT_DATE('%Y-%m', DATE(usage_start_time)) as month,
service.description as service_name,
SUM(cost) as total_cost,
currency
FROM \`${PROJECT_ID}.${DATASET_ID}.${tableName}\`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL ${months} MONTH)
GROUP BY month, service.description, currency
HAVING total_cost > 0
ORDER BY month DESC, total_cost DESC
`;
const [rows] = await bigquery.query(query);
// 월별로 그룹화
const byMonth = {};
rows.forEach(row => {
if (!byMonth[row.month]) {
byMonth[row.month] = { month: row.month, services: [], total: 0 };
}
byMonth[row.month].services.push({
serviceName: row.service_name,
cost: parseFloat(row.total_cost?.toFixed(6) || 0),
currency: row.currency
});
byMonth[row.month].total += row.total_cost || 0;
});
return {
period: `${months}개월`,
monthly: Object.values(byMonth).map(m => ({
...m,
total: parseFloat(m.total.toFixed(6))
}))
};
} catch (error) {
console.error('[BillingService] 월별 비용 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* Gemini/Vertex AI 비용만 조회
*/
const getGeminiCost = async (days = 30) => {
return getCostBySKU(days, 'vertex ai');
};
/**
* 전체 비용 대시보드 데이터
*/
const getBillingDashboard = async (days = 30) => {
try {
const [byService, dailyTrend, geminiCost] = await Promise.all([
getCostByService(days),
getDailyCostTrend(days),
getGeminiCost(days)
]);
// 총 비용 계산
const totalCost = byService.services?.reduce((sum, s) => sum + s.actualCost, 0) || 0;
// KRW 환산
const usdToKrw = 1350;
return {
period: `${days}`,
summary: {
totalCostUSD: totalCost.toFixed(4),
totalCostKRW: Math.round(totalCost * usdToKrw),
serviceCount: byService.services?.length || 0
},
byService: byService.services || [],
dailyTrend: dailyTrend.daily || [],
geminiUsage: geminiCost.skus || [],
exchangeRate: { USD_KRW: usdToKrw },
dataSource: 'Google Cloud BigQuery (실제 결제 데이터)',
error: byService.error || dailyTrend.error || null
};
} catch (error) {
console.error('[BillingService] 대시보드 데이터 조회 오류:', error.message);
return { error: error.message };
}
};
/**
* 결제 데이터 사용 가능 여부 확인
*/
const checkBillingDataAvailable = async () => {
try {
const tableName = await findBillingTable();
return {
available: !!tableName,
tableName: tableName,
message: tableName
? '결제 데이터를 사용할 수 있습니다.'
: '결제 데이터 테이블이 아직 생성되지 않았습니다. BigQuery Export 설정 후 24-48시간이 필요합니다.'
};
} catch (error) {
return {
available: false,
error: error.message
};
}
};
module.exports = {
getCostByService,
getDailyCostTrend,
getCostBySKU,
getMonthlyCost,
getGeminiCost,
getBillingDashboard,
checkBillingDataAvailable
};