feat: 외화 결제 입력 — 통화+환율→원화 환산, 원본 보존 + 환율 API

- account_entry에 currency/original_amount/exchange_rate 컬럼 추가(멱등 ALTER)
  amount는 환산 원화(표준값) 유지 → 통계·예산 무변경
- AccountEntry/Request/Response·매퍼·서비스에 외화 필드 전달
- FxService(open.er-api.com 무료·무인증, 통화별 일자 캐시) + FxController
  GET /api/fx/rate?from=USD&to=KRW → { from, to, rate }
- WebConfig: /api/fx/** 인증 보호
- (.gitignore: 로컬 시드 스크립트 제외 규칙)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-30 21:37:04 +09:00
co-authored by Claude Opus 4.8
parent b969ba6104
commit 10f51976d9
10 changed files with 143 additions and 3 deletions
@@ -234,6 +234,9 @@ public class AccountService {
.walletId(req.getWalletId())
.toWalletId(transfer ? req.getToWalletId() : null)
.installmentMonths(installmentOf(req))
.currency(req.getCurrency())
.originalAmount(req.getOriginalAmount())
.exchangeRate(req.getExchangeRate())
.build();
mapper.insert(entry);
applyTags(entry.getId(), req.getTagIds(), memberId);
@@ -253,6 +256,9 @@ public class AccountService {
entry.setWalletId(req.getWalletId());
entry.setToWalletId(transfer ? req.getToWalletId() : null);
entry.setInstallmentMonths(installmentOf(req));
entry.setCurrency(req.getCurrency());
entry.setOriginalAmount(req.getOriginalAmount());
entry.setExchangeRate(req.getExchangeRate());
mapper.update(entry);
mapper.deleteEntryTagsByEntryId(id);
@@ -0,0 +1,64 @@
package com.sb.web.account.service;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 환율 조회. open.er-api.com(무료·무인증) 사용. 통화(base)별로 하루 1회만 호출하고 캐시.
* 실패해도 예외를 던지지 않고 null(또는 직전 캐시) 반환.
*/
@Slf4j
@Service
public class FxService {
private static final String URL = "https://open.er-api.com/v6/latest/{base}";
private final RestClient restClient = RestClient.builder().build();
private record Cached(LocalDate date, JsonNode rates) {}
private final Map<String, Cached> cache = new ConcurrentHashMap<>();
/** from 통화 1단위 → to 통화 환율. 조회 실패 시 null. */
public BigDecimal getRate(String from, String to) {
if (from == null || to == null) return null;
String base = from.trim().toUpperCase();
String target = to.trim().toUpperCase();
if (base.isEmpty() || target.isEmpty()) return null;
if (base.equals(target)) return BigDecimal.ONE;
JsonNode rates = ratesFor(base);
if (rates == null) return null;
JsonNode r = rates.get(target);
return (r != null && r.isNumber()) ? r.decimalValue() : null;
}
private JsonNode ratesFor(String base) {
Cached c = cache.get(base);
LocalDate today = LocalDate.now();
if (c != null && c.date().equals(today)) return c.rates();
try {
JsonNode root = restClient.get()
.uri(URL, base)
.header("User-Agent", "Mozilla/5.0")
.retrieve()
.body(JsonNode.class);
if (root == null || !"success".equals(root.path("result").asText())) {
return c != null ? c.rates() : null;
}
JsonNode rates = root.path("rates");
if (rates.isMissingNode() || !rates.isObject()) {
return c != null ? c.rates() : null;
}
cache.put(base, new Cached(today, rates));
return rates;
} catch (Exception e) {
log.warn("환율 조회 실패 base={}: {}", base, e.toString());
return c != null ? c.rates() : null; // 실패 시 직전 캐시라도
}
}
}