63 lines
4.8 KiB
JavaScript
63 lines
4.8 KiB
JavaScript
'use strict';
|
|||
|
|
// 원/달러 환율: 날짜별 캐시(data/fx.json). 자동 모드는 frankfurter(ECB 기준) → open.er-api 순으로 조회하고 스프레드를 더해 "구입 가격"을 근사한다.
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const https = require('https');
|
||
|
|
const settings = require('./settings');
|
||
|
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||
|
|
const FILE = path.join(DATA_ROOT, 'fx.json');
|
||
|
|
|
||
|
|
function loadCache() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return {}; } }
|
||
|
|
function saveCache(c) { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(c, null, 1)); }
|
||
|
|
function getJson(url) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const req = https.get(url, { timeout: 8000, headers: { 'User-Agent': 'WebHostingAgent/1.0' } }, (res) => {
|
||
|
|
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); return resolve(getJson(new URL(res.headers.location, url).toString())); }
|
||
|
|
let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('응답 파싱 실패')); } });
|
||
|
|
});
|
||
|
|
req.on('timeout', () => req.destroy(new Error('시간 초과'))); req.on('error', reject);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/** KST 기준 날짜 문자열 (YYYY-MM-DD) */
|
||
|
|
function kstDate(d = new Date()) { return new Date(new Date(d).getTime() + 9 * 3600 * 1000).toISOString().slice(0, 10); }
|
||
|
|
|
||
|
|
async function fetchMid(date) {
|
||
|
|
const today = kstDate();
|
||
|
|
try { const j = await getJson(`https://api.frankfurter.dev/v1/${date === today ? 'latest' : date}?base=USD&symbols=KRW`); if (j && j.rates && j.rates.KRW) return { mid: j.rates.KRW, source: `ECB(frankfurter) ${j.date}` }; } catch (_) {}
|
||
|
|
if (date === today) { try { const j = await getJson('https://open.er-api.com/v6/latest/USD'); if (j && j.rates && j.rates.KRW) return { mid: j.rates.KRW, source: `open.er-api ${String(j.time_last_update_utc || '').slice(0, 16)}` }; } catch (_) {} }
|
||
|
|
throw new Error('환율 조회 실패');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 해당 날짜의 USD 구입가(원). 반환 {date, rate, mid, spreadPct, source} */
|
||
|
|
async function getRate(date) {
|
||
|
|
const s = settings.load(); date = date || kstDate();
|
||
|
|
if (s.fx.mode === 'manual' && s.fx.manualRate > 0) return { date, rate: s.fx.manualRate, mid: s.fx.manualRate, spreadPct: 0, source: '관리자 입력' };
|
||
|
|
const cache = loadCache();
|
||
|
|
let entry = cache[date];
|
||
|
|
const today = kstDate();
|
||
|
|
if (!entry || (date === today && Date.now() - Date.parse(entry.fetchedAt || 0) > 6 * 3600 * 1000)) {
|
||
|
|
try { const m = await fetchMid(date); entry = { mid: m.mid, source: m.source, fetchedAt: new Date().toISOString() }; cache[date] = entry; saveCache(cache); }
|
||
|
|
catch (e) { if (!entry) { const prev = Object.keys(cache).filter((k) => k < date).sort().pop(); if (prev) entry = { ...cache[prev], source: `${cache[prev].source} (${prev} 값 대체)` }; else if (s.fx.manualRate > 0) entry = { mid: s.fx.manualRate, source: '관리자 입력(조회 실패 대체)', fetchedAt: null }; else throw e; } }
|
||
|
|
}
|
||
|
|
const rate = Math.round(entry.mid * (1 + (s.fx.spreadPct || 0) / 100) * 100) / 100;
|
||
|
|
return { date, rate, mid: entry.mid, spreadPct: s.fx.spreadPct || 0, source: entry.source };
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* v1.4.0 다른 통화 1단위 = 몇 USD 인지 (도메인 가격은 EUR 등으로 온다). 하루 한 번 조회해 fx.json 에 'cross:<통화>:<날짜>' 로 둔다.
|
||
|
|
* 조회에 실패하면 가장 최근 값을 쓰고, 그것도 없으면 오류(가격을 모르고 청구하지 않게).
|
||
|
|
*/
|
||
|
|
async function usdPer(currency, date) {
|
||
|
|
const cur = String(currency || 'USD').toUpperCase(); if (cur === 'USD') return 1;
|
||
|
|
if (!/^[A-Z]{3}$/.test(cur)) throw new Error('통화 코드가 올바르지 않습니다');
|
||
|
|
date = date || kstDate(); const key = `cross:${cur}:${date}`; const cache = loadCache();
|
||
|
|
if (cache[key]) return cache[key].v;
|
||
|
|
let v = null;
|
||
|
|
try { const j = await getJson(`https://api.frankfurter.dev/v1/latest?base=${cur}&symbols=USD`); if (j && j.rates && j.rates.USD) v = j.rates.USD; } catch (_) {}
|
||
|
|
if (!v) { try { const j = await getJson(`https://open.er-api.com/v6/latest/${cur}`); if (j && j.rates && j.rates.USD) v = j.rates.USD; } catch (_) {} }
|
||
|
|
if (!v) { const prev = Object.keys(cache).filter((k) => k.startsWith(`cross:${cur}:`)).sort().pop(); if (prev) return cache[prev].v; throw new Error(`${cur} 환율 조회 실패`); }
|
||
|
|
cache[key] = { v, fetchedAt: new Date().toISOString() }; saveCache(cache); return v;
|
||
|
|
}
|
||
|
|
/** 금액(통화) → 원화 (USD 구입가 기준 — 스프레드 포함) */
|
||
|
|
async function toKrw(amount, currency, date) { const usd = Number(amount) * await usdPer(currency, date); const r = await getRate(date); return { krw: usd * r.rate, usd, rate: r.rate }; }
|
||
|
|
module.exports = { getRate, kstDate, loadCache, usdPer, toKrw };
|