Compare commits
2 Commits
204c5ce430
...
3535c2e220
| Author | SHA1 | Date | |
|---|---|---|---|
| 3535c2e220 | |||
| 10f51976d9 |
@@ -2,6 +2,10 @@ HELP.md
|
|||||||
.gradle
|
.gradle
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# 로컬 전용 테스트/시드 스크립트 (실제 이메일·프리미엄 부여 포함 — 커밋 금지)
|
||||||
|
scripts/seed-test-*.sql
|
||||||
|
scripts/cleanup-test-*.sql
|
||||||
|
|
||||||
# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적)
|
# 환경변수 / 비밀 정보 (실제 .env 는 커밋 금지, .env.example 만 추적)
|
||||||
.env
|
.env
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!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 lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ public class AccountEntry implements Serializable {
|
|||||||
private Long toWalletId; // 이체 입금 계좌
|
private Long toWalletId; // 이체 입금 계좌
|
||||||
private String toWalletName;
|
private String toWalletName;
|
||||||
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
||||||
|
private String currency; // 외화 통화코드(USD 등). null/KRW = 원화
|
||||||
|
private BigDecimal originalAmount; // 외화 원본 금액
|
||||||
|
private BigDecimal exchangeRate; // 적용 환율(외화 1단위→원화)
|
||||||
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
||||||
private String notifKey; // 알림 자동인식 중복방지 키
|
private String notifKey; // 알림 자동인식 중복방지 키
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.PositiveOrZero;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -48,4 +49,10 @@ public class AccountEntryRequest {
|
|||||||
|
|
||||||
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
|
/** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */
|
||||||
private List<Long> tagIds;
|
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.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ public class AccountEntryResponse {
|
|||||||
private String toWalletName;
|
private String toWalletName;
|
||||||
private Integer installmentMonths;
|
private Integer installmentMonths;
|
||||||
private Boolean pending;
|
private Boolean pending;
|
||||||
|
private String currency;
|
||||||
|
private BigDecimal originalAmount;
|
||||||
|
private BigDecimal exchangeRate;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
|
|
||||||
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
||||||
@@ -42,6 +46,9 @@ public class AccountEntryResponse {
|
|||||||
.toWalletName(e.getToWalletName())
|
.toWalletName(e.getToWalletName())
|
||||||
.installmentMonths(e.getInstallmentMonths())
|
.installmentMonths(e.getInstallmentMonths())
|
||||||
.pending(Boolean.TRUE.equals(e.getPending()))
|
.pending(Boolean.TRUE.equals(e.getPending()))
|
||||||
|
.currency(e.getCurrency())
|
||||||
|
.originalAmount(e.getOriginalAmount())
|
||||||
|
.exchangeRate(e.getExchangeRate())
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ public class AccountService {
|
|||||||
.walletId(req.getWalletId())
|
.walletId(req.getWalletId())
|
||||||
.toWalletId(transfer ? req.getToWalletId() : null)
|
.toWalletId(transfer ? req.getToWalletId() : null)
|
||||||
.installmentMonths(installmentOf(req))
|
.installmentMonths(installmentOf(req))
|
||||||
|
.currency(req.getCurrency())
|
||||||
|
.originalAmount(req.getOriginalAmount())
|
||||||
|
.exchangeRate(req.getExchangeRate())
|
||||||
.build();
|
.build();
|
||||||
mapper.insert(entry);
|
mapper.insert(entry);
|
||||||
applyTags(entry.getId(), req.getTagIds(), memberId);
|
applyTags(entry.getId(), req.getTagIds(), memberId);
|
||||||
@@ -253,6 +256,9 @@ public class AccountService {
|
|||||||
entry.setWalletId(req.getWalletId());
|
entry.setWalletId(req.getWalletId());
|
||||||
entry.setToWalletId(transfer ? req.getToWalletId() : null);
|
entry.setToWalletId(transfer ? req.getToWalletId() : null);
|
||||||
entry.setInstallmentMonths(installmentOf(req));
|
entry.setInstallmentMonths(installmentOf(req));
|
||||||
|
entry.setCurrency(req.getCurrency());
|
||||||
|
entry.setOriginalAmount(req.getOriginalAmount());
|
||||||
|
entry.setExchangeRate(req.getExchangeRate());
|
||||||
mapper.update(entry);
|
mapper.update(entry);
|
||||||
|
|
||||||
mapper.deleteEntryTagsByEntryId(id);
|
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",
|
.addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
|
||||||
"/api/auth/logout", "/api/auth/password",
|
"/api/auth/logout", "/api/auth/password",
|
||||||
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
|
"/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 이 호출
|
// Google Play RTDN(서버 알림)은 비인증 — Google 이 호출
|
||||||
.excludePathPatterns("/api/billing/google/rtdn");
|
.excludePathPatterns("/api/billing/google/rtdn");
|
||||||
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
||||||
|
|||||||
@@ -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 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 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);
|
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 (
|
CREATE TABLE IF NOT EXISTS account_tag (
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
<result property="installmentMonths" column="installment_months"/>
|
<result property="installmentMonths" column="installment_months"/>
|
||||||
<result property="pending" column="pending"/>
|
<result property="pending" column="pending"/>
|
||||||
<result property="notifKey" column="notif_key"/>
|
<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="createdAt" column="created_at"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
e.wallet_id, w.name AS wallet_name,
|
e.wallet_id, w.name AS wallet_name,
|
||||||
e.to_wallet_id, tw.name AS to_wallet_name,
|
e.to_wallet_id, tw.name AS to_wallet_name,
|
||||||
e.installment_months, e.pending, e.notif_key,
|
e.installment_months, e.pending, e.notif_key,
|
||||||
|
e.currency, e.original_amount, e.exchange_rate,
|
||||||
e.created_at, e.updated_at
|
e.created_at, e.updated_at
|
||||||
</sql>
|
</sql>
|
||||||
<sql id="entryJoins">
|
<sql id="entryJoins">
|
||||||
@@ -141,17 +145,20 @@
|
|||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
|
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
|
||||||
wallet_id, to_wallet_id, installment_months, pending, notif_key,
|
wallet_id, to_wallet_id, installment_months, pending, notif_key,
|
||||||
|
currency, original_amount, exchange_rate,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
|
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
|
||||||
#{walletId}, #{toWalletId}, #{installmentMonths},
|
#{walletId}, #{toWalletId}, #{installmentMonths},
|
||||||
COALESCE(#{pending}, 0), #{notifKey}, NOW(), NOW())
|
COALESCE(#{pending}, 0), #{notifKey},
|
||||||
|
#{currency}, #{originalAmount}, #{exchangeRate}, NOW(), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
|
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
|
||||||
UPDATE account_entry
|
UPDATE account_entry
|
||||||
SET entry_date = #{entryDate}, type = #{type}, category = #{category},
|
SET entry_date = #{entryDate}, type = #{type}, category = #{category},
|
||||||
amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId},
|
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}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user