From 10f51976d912afb536436f59a373789ef708668a Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Tue, 30 Jun 2026 21:37:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=B8=ED=99=94=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=E2=80=94=20=ED=86=B5=ED=99=94+?= =?UTF-8?q?=ED=99=98=EC=9C=A8=E2=86=92=EC=9B=90=ED=99=94=20=ED=99=98?= =?UTF-8?q?=EC=82=B0,=20=EC=9B=90=EB=B3=B8=20=EB=B3=B4=EC=A1=B4=20+=20?= =?UTF-8?q?=ED=99=98=EC=9C=A8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 ++ .../web/account/controller/FxController.java | 37 +++++++++++ .../sb/web/account/domain/AccountEntry.java | 4 ++ .../web/account/dto/AccountEntryRequest.java | 7 ++ .../web/account/dto/AccountEntryResponse.java | 7 ++ .../web/account/service/AccountService.java | 6 ++ .../com/sb/web/account/service/FxService.java | 64 +++++++++++++++++++ .../com/sb/web/common/config/WebConfig.java | 2 +- src/main/resources/db/account.sql | 4 ++ .../resources/mapper/AccountEntryMapper.xml | 11 +++- 10 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/sb/web/account/controller/FxController.java create mode 100644 src/main/java/com/sb/web/account/service/FxService.java diff --git a/.gitignore b/.gitignore index ac86434..13df9c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ HELP.md .gradle build/ +# 로컬 전용 테스트/시드 스크립트 (실제 이메일·프리미엄 부여 포함 — 커밋 금지) +scripts/seed-test-*.sql +scripts/cleanup-test-*.sql + # 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적) .env !gradle/wrapper/gradle-wrapper.jar diff --git a/src/main/java/com/sb/web/account/controller/FxController.java b/src/main/java/com/sb/web/account/controller/FxController.java new file mode 100644 index 0000000..9ee1c54 --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/FxController.java @@ -0,0 +1,37 @@ +package com.sb.web.account.controller; + +import com.sb.web.account.service.FxService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +/** + * 환율 조회 API. 외화 결제 입력 시 통화→원화 환율 자동 채움용. + * GET /api/fx/rate?from=USD&to=KRW → { from, to, rate } + */ +@RestController +@RequestMapping("/api/fx") +public class FxController { + + private final FxService fxService; + + public FxController(FxService fxService) { + this.fxService = fxService; + } + + @GetMapping("/rate") + public Map rate(@RequestParam String from, + @RequestParam(defaultValue = "KRW") String to) { + BigDecimal rate = fxService.getRate(from, to); + Map res = new HashMap<>(); + res.put("from", from == null ? null : from.toUpperCase()); + res.put("to", to == null ? null : to.toUpperCase()); + res.put("rate", rate); // 조회 실패 시 null + return res; + } +} diff --git a/src/main/java/com/sb/web/account/domain/AccountEntry.java b/src/main/java/com/sb/web/account/domain/AccountEntry.java index 113d9e2..bffea60 100644 --- a/src/main/java/com/sb/web/account/domain/AccountEntry.java +++ b/src/main/java/com/sb/web/account/domain/AccountEntry.java @@ -6,6 +6,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; @@ -30,6 +31,9 @@ public class AccountEntry implements Serializable { private Long toWalletId; // 이체 입금 계좌 private String toWalletName; private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null) + private String currency; // 외화 통화코드(USD 등). null/KRW = 원화 + private BigDecimal originalAmount; // 외화 원본 금액 + private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화) private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건 private String notifKey; // 알림 자동인식 중복방지 키 private LocalDateTime createdAt; diff --git a/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java b/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java index 7363b7c..81ac858 100644 --- a/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java +++ b/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java @@ -9,6 +9,7 @@ import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; @@ -48,4 +49,10 @@ public class AccountEntryRequest { /** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */ private List tagIds; + + /** 외화 결제(선택). currency 가 있으면 amount 는 환산된 원화여야 한다. */ + @Size(max = 3, message = "통화코드가 올바르지 않습니다.") + private String currency; // USD/JPY 등. null/KRW = 원화 + private BigDecimal originalAmount; // 외화 원본 금액 + private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화) } diff --git a/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java index 349c8f8..4f90f78 100644 --- a/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java +++ b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java @@ -4,6 +4,7 @@ import com.sb.web.account.domain.AccountEntry; import lombok.Builder; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; @@ -26,6 +27,9 @@ public class AccountEntryResponse { private String toWalletName; private Integer installmentMonths; private Boolean pending; + private String currency; + private BigDecimal originalAmount; + private BigDecimal exchangeRate; private List tags; public static AccountEntryResponse from(AccountEntry e, List tags) { @@ -42,6 +46,9 @@ public class AccountEntryResponse { .toWalletName(e.getToWalletName()) .installmentMonths(e.getInstallmentMonths()) .pending(Boolean.TRUE.equals(e.getPending())) + .currency(e.getCurrency()) + .originalAmount(e.getOriginalAmount()) + .exchangeRate(e.getExchangeRate()) .tags(tags) .build(); } diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java index b6bd92c..308bdb2 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -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); diff --git a/src/main/java/com/sb/web/account/service/FxService.java b/src/main/java/com/sb/web/account/service/FxService.java new file mode 100644 index 0000000..527663c --- /dev/null +++ b/src/main/java/com/sb/web/account/service/FxService.java @@ -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 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; // 실패 시 직전 캐시라도 + } + } +} diff --git a/src/main/java/com/sb/web/common/config/WebConfig.java b/src/main/java/com/sb/web/common/config/WebConfig.java index 05cc21c..7a456db 100644 --- a/src/main/java/com/sb/web/common/config/WebConfig.java +++ b/src/main/java/com/sb/web/common/config/WebConfig.java @@ -28,7 +28,7 @@ public class WebConfig implements WebMvcConfigurer { .addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history", "/api/auth/logout", "/api/auth/password", "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", - "/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**") + "/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**", "/api/fx/**") // Google Play RTDN(서버 알림)은 비인증 — Google 이 호출 .excludePathPatterns("/api/billing/google/rtdn"); // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사) diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 9aa7076..936711e 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -59,6 +59,10 @@ ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS installment_months INT NULL; ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부'; ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키'; ALTER TABLE account_entry ADD INDEX IF NOT EXISTS idx_entry_notif (member_id, notif_key); +-- 외화 결제: amount 는 환산된 원화(표준값, 통계·예산은 이 값 사용). 아래는 외화 원본 보존용. +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS currency VARCHAR(3) NULL COMMENT '통화코드(USD/JPY 등). NULL/KRW=원화'; +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS original_amount DECIMAL(18,2) NULL COMMENT '외화 원본 금액'; +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS exchange_rate DECIMAL(18,6) NULL COMMENT '적용 환율(외화 1단위→원화)'; -- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리) CREATE TABLE IF NOT EXISTS account_tag ( diff --git a/src/main/resources/mapper/AccountEntryMapper.xml b/src/main/resources/mapper/AccountEntryMapper.xml index 8f5fca5..93f32d9 100644 --- a/src/main/resources/mapper/AccountEntryMapper.xml +++ b/src/main/resources/mapper/AccountEntryMapper.xml @@ -18,6 +18,9 @@ + + + @@ -32,6 +35,7 @@ e.wallet_id, w.name AS wallet_name, e.to_wallet_id, tw.name AS to_wallet_name, e.installment_months, e.pending, e.notif_key, + e.currency, e.original_amount, e.exchange_rate, e.created_at, e.updated_at @@ -141,17 +145,20 @@ useGeneratedKeys="true" keyProperty="id"> INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo, wallet_id, to_wallet_id, installment_months, pending, notif_key, + currency, original_amount, exchange_rate, created_at, updated_at) VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo}, #{walletId}, #{toWalletId}, #{installmentMonths}, - COALESCE(#{pending}, 0), #{notifKey}, NOW(), NOW()) + COALESCE(#{pending}, 0), #{notifKey}, + #{currency}, #{originalAmount}, #{exchangeRate}, NOW(), NOW()) UPDATE account_entry SET entry_date = #{entryDate}, type = #{type}, category = #{category}, amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId}, - installment_months = #{installmentMonths} + installment_months = #{installmentMonths}, + currency = #{currency}, original_amount = #{originalAmount}, exchange_rate = #{exchangeRate} WHERE id = #{id} AND member_id = #{memberId}