Compare commits

..

2 Commits

Author SHA1 Message Date
ByungCheol 3535c2e220 Merge branch 'dev'
Deploy / deploy (push) Failing after 12m21s
2026-06-30 21:37:07 +09:00
ByungCheol 10f51976d9 feat: 외화 결제 입력 — 통화+환율→원화 환산, 원본 보존 + 환율 API
CI / build (push) Failing after 14m19s
- 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>
2026-06-30 21:37:04 +09:00
10 changed files with 143 additions and 3 deletions
+4
View File
@@ -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
@@ -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<String, Object> rate(@RequestParam String from,
@RequestParam(defaultValue = "KRW") String to) {
BigDecimal rate = fxService.getRate(from, to);
Map<String, Object> 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;
}
}
@@ -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;
@@ -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<Long> tagIds;
/** 외화 결제(선택). currency 가 있으면 amount 는 환산된 원화여야 한다. */
@Size(max = 3, message = "통화코드가 올바르지 않습니다.")
private String currency; // USD/JPY 등. null/KRW = 원화
private BigDecimal originalAmount; // 외화 원본 금액
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
}
@@ -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<String> tags;
public static AccountEntryResponse from(AccountEntry e, List<String> 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();
}
@@ -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; // 실패 시 직전 캐시라도
}
}
}
@@ -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 검사)
+4
View File
@@ -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 (
@@ -18,6 +18,9 @@
<result property="installmentMonths" column="installment_months"/>
<result property="pending" column="pending"/>
<result property="notifKey" column="notif_key"/>
<result property="currency" column="currency"/>
<result property="originalAmount" column="original_amount"/>
<result property="exchangeRate" column="exchange_rate"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
@@ -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
</sql>
<sql id="entryJoins">
@@ -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())
</insert>
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
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}
</update>