310 lines
10 KiB
JavaScript
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
|
|
};
|