From fb94df81120937c8de976ffa0c9961ef1290e6e5 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Thu, 4 Jun 2026 05:00:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EC=84=B8=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=C2=B7=ED=87=B4=EC=A7=81=EC=97=B0=EA=B8=88=20?= =?UTF-8?q?=ED=8F=89=EA=B0=80=EC=95=A1=C2=B7=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=C2=B7=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=C2=B7=EA=B3=84=EC=A2=8C=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=95=94=ED=98=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 투자: 종목코드로 현재가 시세 자동조회(StockQuoteService, 투자탭/포트폴리오 진입 시 갱신) - 투자: 퇴직연금 등 평가액 직접입력형 계좌(currentValue 활성화, manualValuation) - 게시판: community/saving/tips 카테고리 분리(post.category + 목록 필터) - 태그: 순서 변경(account_tag.sort_order, /tags/reorder) - 보안: 계좌번호 AES-256-GCM 암호화 저장/복호화(EncryptedStringTypeHandler, account_number VARCHAR(255)) 키는 서버 .env ACCOUNT_CRYPTO_KEY 로 주입(미설정 시 평문 통과) Co-Authored-By: Claude Opus 4.8 --- .../account/controller/AccountController.java | 9 ++ .../account/controller/InvestController.java | 16 ++++ .../com/sb/web/account/domain/AccountTag.java | 1 + .../sb/web/account/dto/TagReorderRequest.java | 14 ++++ .../sb/web/account/dto/WalletResponse.java | 3 + .../web/account/mapper/AccountTagMapper.java | 4 + .../web/account/service/AccountService.java | 54 +++++++++--- .../sb/web/account/service/InvestService.java | 32 ++++++++ .../account/service/StockQuoteService.java | 43 ++++++++++ .../web/board/controller/BoardController.java | 3 +- .../java/com/sb/web/board/domain/Post.java | 1 + .../java/com/sb/web/board/dto/PostDetail.java | 3 + .../com/sb/web/board/dto/PostRequest.java | 3 + .../com/sb/web/board/mapper/PostMapper.java | 4 +- .../sb/web/board/service/BoardService.java | 20 ++++- .../sb/web/common/crypto/CryptoConfig.java | 36 ++++++++ .../crypto/EncryptedStringTypeHandler.java | 43 ++++++++++ .../com/sb/web/common/crypto/FieldCrypto.java | 82 +++++++++++++++++++ src/main/resources/application.yml | 7 ++ src/main/resources/db/account.sql | 6 +- src/main/resources/db/board.sql | 6 +- .../resources/mapper/AccountTagMapper.xml | 20 +++-- src/main/resources/mapper/PostMapper.xml | 11 ++- src/main/resources/mapper/WalletMapper.xml | 9 +- 24 files changed, 401 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/sb/web/account/dto/TagReorderRequest.java create mode 100644 src/main/java/com/sb/web/account/service/StockQuoteService.java create mode 100644 src/main/java/com/sb/web/common/crypto/CryptoConfig.java create mode 100644 src/main/java/com/sb/web/common/crypto/EncryptedStringTypeHandler.java create mode 100644 src/main/java/com/sb/web/common/crypto/FieldCrypto.java diff --git a/src/main/java/com/sb/web/account/controller/AccountController.java b/src/main/java/com/sb/web/account/controller/AccountController.java index 1dd5e39..2f3c788 100644 --- a/src/main/java/com/sb/web/account/controller/AccountController.java +++ b/src/main/java/com/sb/web/account/controller/AccountController.java @@ -8,6 +8,7 @@ import com.sb.web.account.dto.AccountTagResponse; import com.sb.web.account.dto.CategoryStat; import com.sb.web.account.dto.ConfirmRequest; import com.sb.web.account.dto.NotificationRequest; +import com.sb.web.account.dto.TagReorderRequest; import com.sb.web.account.dto.NetWorthPoint; import com.sb.web.account.dto.NetWorthResponse; import com.sb.web.account.dto.PeriodStat; @@ -135,6 +136,14 @@ public class AccountController { return ResponseEntity.noContent().build(); } + /** 태그 순서 변경 */ + @PutMapping("/tags/reorder") + public List reorderTags( + @RequestBody TagReorderRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return accountService.reorderTags(current.getId(), req.getIds()); + } + @GetMapping("/entries") public List list( @RequestParam(required = false) Integer year, diff --git a/src/main/java/com/sb/web/account/controller/InvestController.java b/src/main/java/com/sb/web/account/controller/InvestController.java index c4d0fed..745b44a 100644 --- a/src/main/java/com/sb/web/account/controller/InvestController.java +++ b/src/main/java/com/sb/web/account/controller/InvestController.java @@ -46,6 +46,22 @@ public class InvestController { return ResponseEntity.status(HttpStatus.CREATED).body(investService.createHolding(req, current.getId())); } + /** 종목코드로 현재가 시세를 일괄 갱신 */ + @PostMapping("/holdings/refresh-prices") + public List refreshPrices( + @RequestParam Long walletId, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return investService.refreshPrices(current.getId(), walletId); + } + + /** 모든 투자계좌 보유종목 현재가 일괄 갱신(계좌 목록 평가액 반영용) */ + @PostMapping("/refresh-prices") + public ResponseEntity refreshAllPrices( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + investService.refreshAllPrices(current.getId()); + return ResponseEntity.noContent().build(); + } + @PutMapping("/holdings/{id}") public HoldingResponse updateHolding( @PathVariable Long id, diff --git a/src/main/java/com/sb/web/account/domain/AccountTag.java b/src/main/java/com/sb/web/account/domain/AccountTag.java index 93c36fa..9c1bfa2 100644 --- a/src/main/java/com/sb/web/account/domain/AccountTag.java +++ b/src/main/java/com/sb/web/account/domain/AccountTag.java @@ -20,5 +20,6 @@ public class AccountTag implements Serializable { private Long id; private Long memberId; private String name; + private Integer sortOrder; private LocalDateTime createdAt; } diff --git a/src/main/java/com/sb/web/account/dto/TagReorderRequest.java b/src/main/java/com/sb/web/account/dto/TagReorderRequest.java new file mode 100644 index 0000000..1339eb4 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/TagReorderRequest.java @@ -0,0 +1,14 @@ +package com.sb.web.account.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 태그 순서 변경 요청. ids 순서대로 sort_order 를 재설정한다. + */ +@Data +public class TagReorderRequest { + + private List ids; // 새 순서의 태그 id 목록 +} diff --git a/src/main/java/com/sb/web/account/dto/WalletResponse.java b/src/main/java/com/sb/web/account/dto/WalletResponse.java index cc03dca..408d67c 100644 --- a/src/main/java/com/sb/web/account/dto/WalletResponse.java +++ b/src/main/java/com/sb/web/account/dto/WalletResponse.java @@ -28,6 +28,8 @@ public class WalletResponse { private Long deposit; // 예수금(미투자 현금) = 투입원금 + 매도 − 매수 private Long stockValue; // 주식 평가금액 private Long valuationGain; // 총손익(실현+평가) = 총평가 − 투입원금 + private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용 + private Boolean manualValuation; // true면 평가액 직접입력형(종목 자동계산 대신) public static WalletResponse from(Wallet w, long balance) { return WalletResponse.builder() @@ -40,6 +42,7 @@ public class WalletResponse { .openingBalance(w.getOpeningBalance()) .openingDate(w.getOpeningDate()) .balance(balance) + .currentValue(w.getCurrentValue()) .build(); } } diff --git a/src/main/java/com/sb/web/account/mapper/AccountTagMapper.java b/src/main/java/com/sb/web/account/mapper/AccountTagMapper.java index 66b5c32..d7545ad 100644 --- a/src/main/java/com/sb/web/account/mapper/AccountTagMapper.java +++ b/src/main/java/com/sb/web/account/mapper/AccountTagMapper.java @@ -24,6 +24,10 @@ public interface AccountTagMapper { int update(AccountTag tag); + int updateSortOrder(@Param("id") Long id, @Param("memberId") Long memberId, @Param("sortOrder") int sortOrder); + + Integer maxSortOrder(@Param("memberId") Long memberId); + int delete(@Param("id") Long id, @Param("memberId") Long memberId); /** 주어진 ID 중 해당 사용자 소유로 실제 존재하는 태그 ID만 반환 */ 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 8b3ab88..cee57cb 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -332,12 +332,24 @@ public class AccountService { if ("INVEST".equals(w.getType())) { InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest()); long deposit = cash + wi.cashDelta; // 예수금 - long total = deposit + wi.stockEval; // 총평가 - r.setBalance(total); - r.setInvestedAmount(cash); // 순입금(투입원금) - r.setDeposit(deposit); - r.setStockValue(wi.stockEval); - r.setValuationGain(total - cash); // 실현+평가 손익 + if (w.getCurrentValue() != null) { + // 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 총평가로 사용 + long total = w.getCurrentValue(); + r.setBalance(total); + r.setInvestedAmount(cash); // 누적 적립원금(순입금) + r.setDeposit(deposit); + r.setStockValue(total - deposit); + r.setValuationGain(total - cash); + r.setManualValuation(true); + } else { + long total = deposit + wi.stockEval; // 총평가 + r.setBalance(total); + r.setInvestedAmount(cash); // 순입금(투입원금) + r.setDeposit(deposit); + r.setStockValue(wi.stockEval); + r.setValuationGain(total - cash); // 실현+평가 손익 + r.setManualValuation(false); + } } return r; }) @@ -353,9 +365,14 @@ public class AccountService { for (Wallet w : walletMapper.findByMember(memberId)) { long bal = balances.getOrDefault(w.getId(), 0L); if ("INVEST".equals(w.getType())) { - // 투자: 예수금(현금+매매증감) + 주식 평가금액 - InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest()); - assets += bal + wi.cashDelta + wi.stockEval; + if (w.getCurrentValue() != null) { + // 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 자산으로 + assets += w.getCurrentValue(); + } else { + // 투자: 예수금(현금+매매증감) + 주식 평가금액 + InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest()); + assets += bal + wi.cashDelta + wi.stockEval; + } } else if ("BANK".equals(w.getType())) { assets += bal; } else { @@ -490,11 +507,28 @@ public class AccountService { if (tagMapper.countByName(memberId, name, null) > 0) { throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다."); } - AccountTag tag = AccountTag.builder().memberId(memberId).name(name).build(); + Integer max = tagMapper.maxSortOrder(memberId); + AccountTag tag = AccountTag.builder() + .memberId(memberId) + .name(name) + .sortOrder(max == null ? 0 : max + 1) + .build(); tagMapper.insert(tag); return AccountTagResponse.from(tag); } + /** 태그 순서 변경 — ids 순서대로 sort_order 재설정 */ + @Transactional + public List reorderTags(Long memberId, List ids) { + int i = 0; + for (Long id : ids) { + if (tagMapper.findByIdAndMember(id, memberId) != null) { + tagMapper.updateSortOrder(id, memberId, i++); + } + } + return listTags(memberId); + } + @Transactional public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) { AccountTag tag = mustFindTag(id, memberId); diff --git a/src/main/java/com/sb/web/account/service/InvestService.java b/src/main/java/com/sb/web/account/service/InvestService.java index 062f4f7..6a26755 100644 --- a/src/main/java/com/sb/web/account/service/InvestService.java +++ b/src/main/java/com/sb/web/account/service/InvestService.java @@ -31,6 +31,7 @@ public class InvestService { private final InvestMapper investMapper; private final WalletMapper walletMapper; + private final StockQuoteService stockQuoteService; /* ===================== 보유 종목 ===================== */ @@ -73,6 +74,37 @@ public class InvestService { investMapper.deleteHolding(id, memberId); } + /** 종목코드가 있는 보유종목의 현재가를 시세 조회로 갱신. 조회 성공한 종목만 반영 */ + @Transactional + public List refreshPrices(Long memberId, Long walletId) { + requireInvestWallet(walletId, memberId); + Map cache = new HashMap<>(); + for (InvestHolding h : investMapper.findHoldingsByWallet(memberId, walletId)) { + applyQuote(h, cache); + } + return listHoldings(memberId, walletId); + } + + /** 회원의 모든 투자계좌 보유종목 현재가를 일괄 갱신(계좌 목록 평가액 반영용) */ + @Transactional + public void refreshAllPrices(Long memberId) { + Map cache = new HashMap<>(); + for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) { + applyQuote(h, cache); + } + } + + /** 종목코드로 시세를 받아 현재가 갱신(동일 종목코드는 호출당 1회만 조회) */ + private void applyQuote(InvestHolding h, Map cache) { + String ticker = h.getTicker(); + if (ticker == null || ticker.isBlank()) return; + Long price = cache.computeIfAbsent(ticker.trim(), stockQuoteService::getDomesticPrice); + if (price != null && price > 0) { + h.setCurrentPrice(price); + investMapper.updateHolding(h); + } + } + /* ===================== 매매 ===================== */ public List listTrades(Long holdingId, Long memberId) { diff --git a/src/main/java/com/sb/web/account/service/StockQuoteService.java b/src/main/java/com/sb/web/account/service/StockQuoteService.java new file mode 100644 index 0000000..a2f21e2 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/StockQuoteService.java @@ -0,0 +1,43 @@ +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; + +/** + * 국내 상장 종목 현재가(시세) 조회. 네이버 금융 폴링 API 사용(무인증). + * 종목코드(6자리)로 현재가를 가져온다. 실패해도 예외를 던지지 않고 null 반환. + */ +@Slf4j +@Service +public class StockQuoteService { + + private static final String URL = + "https://polling.finance.naver.com/api/realtime/domestic/stock/{code}"; + + private final RestClient restClient = RestClient.builder().build(); + + /** 국내 상장 종목 현재가(원). 조회 실패/미상장/코드없음이면 null */ + public Long getDomesticPrice(String ticker) { + if (ticker == null || ticker.isBlank()) return null; + String code = ticker.trim(); + try { + JsonNode root = restClient.get() + .uri(URL, code) + .header("User-Agent", "Mozilla/5.0") + .retrieve() + .body(JsonNode.class); + if (root == null) return null; + JsonNode datas = root.path("datas"); + if (!datas.isArray() || datas.isEmpty()) return null; + String priceStr = datas.get(0).path("closePrice").asText(null); + if (priceStr == null || priceStr.isBlank()) return null; + String digits = priceStr.replaceAll("[^0-9]", ""); + return digits.isEmpty() ? null : Long.parseLong(digits); + } catch (Exception e) { + log.warn("시세 조회 실패 ticker={}: {}", code, e.toString()); + return null; + } + } +} diff --git a/src/main/java/com/sb/web/board/controller/BoardController.java b/src/main/java/com/sb/web/board/controller/BoardController.java index 095fb6b..858420e 100644 --- a/src/main/java/com/sb/web/board/controller/BoardController.java +++ b/src/main/java/com/sb/web/board/controller/BoardController.java @@ -43,10 +43,11 @@ public class BoardController { public PageResponse list( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String category, @RequestParam(required = false) String tag, @RequestParam(required = false) String keyword, @RequestParam(required = false) String searchType) { - return boardService.list(page, size, tag, keyword, searchType); + return boardService.list(page, size, category, tag, keyword, searchType); } @GetMapping("/posts/{id}") diff --git a/src/main/java/com/sb/web/board/domain/Post.java b/src/main/java/com/sb/web/board/domain/Post.java index 9aca8f1..a05a475 100644 --- a/src/main/java/com/sb/web/board/domain/Post.java +++ b/src/main/java/com/sb/web/board/domain/Post.java @@ -25,6 +25,7 @@ public class Post implements Serializable { private Integer viewCount; private Boolean blocked; private String blockReason; + private String category; // 게시판 구분: community/saving/tips private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/src/main/java/com/sb/web/board/dto/PostDetail.java b/src/main/java/com/sb/web/board/dto/PostDetail.java index 73e0f3a..a4df516 100644 --- a/src/main/java/com/sb/web/board/dto/PostDetail.java +++ b/src/main/java/com/sb/web/board/dto/PostDetail.java @@ -22,6 +22,7 @@ public class PostDetail { private Integer viewCount; private Boolean blocked; private String blockReason; + private String category; private LocalDateTime createdAt; private LocalDateTime updatedAt; private List tags; @@ -37,6 +38,7 @@ public class PostDetail { .viewCount(p.getViewCount()) .blocked(Boolean.TRUE.equals(p.getBlocked())) .blockReason(p.getBlockReason()) + .category(p.getCategory()) .createdAt(p.getCreatedAt()) .updatedAt(p.getUpdatedAt()) .tags(tags) @@ -52,6 +54,7 @@ public class PostDetail { .authorName(p.getAuthorName()) .viewCount(p.getViewCount()) .blocked(true) + .category(p.getCategory()) .createdAt(p.getCreatedAt()) .tags(List.of()) .comments(List.of()) diff --git a/src/main/java/com/sb/web/board/dto/PostRequest.java b/src/main/java/com/sb/web/board/dto/PostRequest.java index 405de76..e837891 100644 --- a/src/main/java/com/sb/web/board/dto/PostRequest.java +++ b/src/main/java/com/sb/web/board/dto/PostRequest.java @@ -19,6 +19,9 @@ public class PostRequest { @NotBlank(message = "내용을 입력하세요.") private String content; + /** 게시판 구분: community/saving/tips. 없으면 community */ + private String category; + /** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */ private List tagIds; } diff --git a/src/main/java/com/sb/web/board/mapper/PostMapper.java b/src/main/java/com/sb/web/board/mapper/PostMapper.java index 3809091..3ddaa4b 100644 --- a/src/main/java/com/sb/web/board/mapper/PostMapper.java +++ b/src/main/java/com/sb/web/board/mapper/PostMapper.java @@ -12,11 +12,13 @@ public interface PostMapper { List findPage(@Param("offset") int offset, @Param("size") int size, + @Param("category") String category, @Param("tag") String tag, @Param("keyword") String keyword, @Param("searchType") String searchType); - long countPage(@Param("tag") String tag, + long countPage(@Param("category") String category, + @Param("tag") String tag, @Param("keyword") String keyword, @Param("searchType") String searchType); diff --git a/src/main/java/com/sb/web/board/service/BoardService.java b/src/main/java/com/sb/web/board/service/BoardService.java index 0fe277c..e89bec9 100644 --- a/src/main/java/com/sb/web/board/service/BoardService.java +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -40,15 +40,16 @@ public class BoardService { /* ===================== 게시글 ===================== */ - public PageResponse list(int page, int size, String tag, String keyword, String searchType) { + public PageResponse list(int page, int size, String category, String tag, String keyword, String searchType) { int p = Math.max(page, 1); int s = Math.min(Math.max(size, 1), 100); int offset = (p - 1) * s; String st = normalizeSearchType(searchType); + String cat = normalizeCategory(category); // 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다. - List content = postMapper.findPage(offset, s, blankToNull(tag), blankToNull(keyword), st); - long total = postMapper.countPage(blankToNull(tag), blankToNull(keyword), st); + List content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st); + long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st); return PageResponse.of(content, p, s, total); } @@ -56,6 +57,18 @@ public class BoardService { return ("content".equals(t) || "comment".equals(t)) ? t : "title"; } + /** 목록 필터용: 빈값이면 null(전체), 알 수 없는 값은 community 로 보정 */ + private String normalizeCategory(String c) { + if (c == null || c.isBlank()) return null; + return ("saving".equals(c) || "tips".equals(c) || "community".equals(c)) ? c : "community"; + } + + /** 저장용: 빈값/알 수 없는 값은 community 로 기본 설정 */ + private String toCategory(String c) { + if (c == null || c.isBlank()) return "community"; + return ("saving".equals(c) || "tips".equals(c)) ? c : "community"; + } + /** 상세 조회 (열람 제한 글은 관리자만 전체 열람, 그 외엔 제한 응답) */ @Transactional public PostDetail get(Long id, SessionUser viewer) { @@ -95,6 +108,7 @@ public class BoardService { .content(req.getContent()) .authorId(author.getId()) .authorName(author.getName()) + .category(toCategory(req.getCategory())) .build(); postMapper.insert(post); applyTags(post.getId(), req.getTagIds()); diff --git a/src/main/java/com/sb/web/common/crypto/CryptoConfig.java b/src/main/java/com/sb/web/common/crypto/CryptoConfig.java new file mode 100644 index 0000000..bd16166 --- /dev/null +++ b/src/main/java/com/sb/web/common/crypto/CryptoConfig.java @@ -0,0 +1,36 @@ +package com.sb.web.common.crypto; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; + +/** + * 기동 시 암호화 키를 {@link FieldCrypto} 에 주입한다. + * 키는 서버 /opt/sb-backend/.env 에 ACCOUNT_CRYPTO_KEY=Base64(32바이트) 로 설정. + * 미설정이면 암호화 비활성(평문 저장) — 경고 로그. + */ +@Slf4j +@Configuration +public class CryptoConfig { + + public CryptoConfig(@Value("${app.crypto.account-key:}") String base64Key) { + if (base64Key == null || base64Key.isBlank()) { + log.warn("[crypto] ACCOUNT_CRYPTO_KEY 미설정 — 계좌번호 암호화가 비활성화됩니다(평문 저장)."); + return; + } + try { + byte[] raw = Base64.getDecoder().decode(base64Key.trim()); + if (raw.length != 16 && raw.length != 24 && raw.length != 32) { + log.error("[crypto] ACCOUNT_CRYPTO_KEY 길이 오류({}바이트). 16/24/32바이트 Base64 여야 합니다. 암호화 비활성.", raw.length); + return; + } + FieldCrypto.init(new SecretKeySpec(raw, "AES")); + log.info("[crypto] 계좌번호 암호화 활성화(AES-{}).", raw.length * 8); + } catch (IllegalArgumentException e) { + log.error("[crypto] ACCOUNT_CRYPTO_KEY Base64 디코딩 실패 — 암호화 비활성. {}", e.toString()); + } + } +} diff --git a/src/main/java/com/sb/web/common/crypto/EncryptedStringTypeHandler.java b/src/main/java/com/sb/web/common/crypto/EncryptedStringTypeHandler.java new file mode 100644 index 0000000..0645877 --- /dev/null +++ b/src/main/java/com/sb/web/common/crypto/EncryptedStringTypeHandler.java @@ -0,0 +1,43 @@ +package com.sb.web.common.crypto; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * MyBatis TypeHandler — 컬럼 저장 시 암호화, 조회 시 복호화. + * 매퍼에서 컬럼별로 명시적으로 지정해 사용한다(전역 등록 금지). + * + *
+ *   resultMap:  <result property="accountNumber" column="account_number"
+ *                       typeHandler="com.sb.web.common.crypto.EncryptedStringTypeHandler"/>
+ *   insert/update: #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}
+ * 
+ */ +public class EncryptedStringTypeHandler extends BaseTypeHandler { + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) + throws SQLException { + ps.setString(i, FieldCrypto.encrypt(parameter)); + } + + @Override + public String getNullableResult(ResultSet rs, String columnName) throws SQLException { + return FieldCrypto.decrypt(rs.getString(columnName)); + } + + @Override + public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + return FieldCrypto.decrypt(rs.getString(columnIndex)); + } + + @Override + public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + return FieldCrypto.decrypt(cs.getString(columnIndex)); + } +} diff --git a/src/main/java/com/sb/web/common/crypto/FieldCrypto.java b/src/main/java/com/sb/web/common/crypto/FieldCrypto.java new file mode 100644 index 0000000..95a0e17 --- /dev/null +++ b/src/main/java/com/sb/web/common/crypto/FieldCrypto.java @@ -0,0 +1,82 @@ +package com.sb.web.common.crypto; + +import lombok.extern.slf4j.Slf4j; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * 민감 필드(계좌번호 등) 양방향 암호화 유틸. AES-256-GCM. + * + *

저장 형식: {@code enc:base64(iv(12바이트) || ciphertext+tag)}. + * 키가 설정되지 않았거나(개발 등) 평문(enc: 접두 없음)이면 그대로 통과시켜 + * 기존 평문 데이터와의 호환을 유지한다(점진적 암호화). + */ +@Slf4j +public final class FieldCrypto { + + private static final String PREFIX = "enc:"; + private static final String TRANSFORM = "AES/GCM/NoPadding"; + private static final int IV_LEN = 12; // GCM 권장 IV 길이(바이트) + private static final int TAG_BITS = 128; // GCM 인증 태그 길이(비트) + private static final SecureRandom RANDOM = new SecureRandom(); + + /** null = 비활성(통과). CryptoConfig 가 기동 시 주입. */ + private static volatile SecretKey key; + + private FieldCrypto() {} + + static void init(SecretKey k) { + key = k; + } + + public static boolean enabled() { + return key != null; + } + + /** 평문 → enc:... (키 없으면 평문 그대로) */ + public static String encrypt(String plain) { + if (plain == null || plain.isEmpty() || key == null) return plain; + if (plain.startsWith(PREFIX)) return plain; // 이미 암호화됨(이중 암호화 방지) + try { + byte[] iv = new byte[IV_LEN]; + RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORM); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return PREFIX + Base64.getEncoder().encodeToString(out); + } catch (Exception e) { + // 암호화 실패 시 데이터 유실 방지를 위해 평문 저장하지 않고 예외 전파 + throw new IllegalStateException("계좌번호 암호화에 실패했습니다.", e); + } + } + + /** enc:... → 평문 (접두 없으면 평문으로 간주해 그대로 반환) */ + public static String decrypt(String stored) { + if (stored == null || !stored.startsWith(PREFIX)) return stored; // 레거시 평문 호환 + if (key == null) { + log.warn("암호화된 계좌번호가 있으나 키가 설정되지 않아 복호화할 수 없습니다."); + return null; + } + try { + byte[] all = Base64.getDecoder().decode(stored.substring(PREFIX.length())); + byte[] iv = new byte[IV_LEN]; + byte[] ct = new byte[all.length - IV_LEN]; + System.arraycopy(all, 0, iv, 0, IV_LEN); + System.arraycopy(all, IV_LEN, ct, 0, ct.length); + Cipher cipher = Cipher.getInstance(TRANSFORM); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + return new String(cipher.doFinal(ct), StandardCharsets.UTF_8); + } catch (Exception e) { + log.warn("계좌번호 복호화 실패: {}", e.toString()); + return null; + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3effacd..85e9e6c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -68,6 +68,13 @@ google: vision: api-key: ${GOOGLE_VISION_API_KEY:} +# ===== 민감 필드 암호화 (계좌번호 등) ===== +# 키는 서버 /opt/sb-backend/.env 에 ACCOUNT_CRYPTO_KEY=Base64(32바이트) 로 주입 (git 미추적). +# 미설정이면 암호화 비활성(평문 저장). 키 분실 시 기존 암호문 복호화 불가하므로 안전하게 보관. +app: + crypto: + account-key: ${ACCOUNT_CRYPTO_KEY:} + # ===== Logging ===== logging: level: diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 68707a4..84604a8 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS wallet ( type VARCHAR(10) NOT NULL COMMENT 'BANK / CARD / LOAN / INVEST', name VARCHAR(50) NOT NULL COMMENT '별칭', issuer VARCHAR(50) NULL COMMENT '은행명/카드사/대출기관/증권사', - account_number VARCHAR(50) NULL COMMENT '계좌번호(BANK)', + account_number VARCHAR(255) NULL COMMENT '계좌번호(BANK) — AES-GCM 암호화 저장', card_type VARCHAR(10) NULL COMMENT 'CREDIT / CHECK (CARD)', opening_balance BIGINT NOT NULL DEFAULT 0 COMMENT '초기 잔액(부호있음: 자산+, 부채-)', opening_date DATE NULL COMMENT '초기잔액 기준일', @@ -28,6 +28,8 @@ ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_balance BIGINT NOT NULL DEFA ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_date DATE NULL; ALTER TABLE wallet ADD COLUMN IF NOT EXISTS current_value BIGINT NULL; ALTER TABLE wallet ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; +-- 계좌번호 암호화 저장(AES-GCM, enc: 접두)으로 평문보다 길어져 컬럼 확장 (멱등) +ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL; CREATE TABLE IF NOT EXISTS account_entry ( id BIGINT NOT NULL AUTO_INCREMENT, @@ -62,11 +64,13 @@ CREATE TABLE IF NOT EXISTS account_tag ( id BIGINT NOT NULL AUTO_INCREMENT, member_id BIGINT NOT NULL COMMENT '소유자 member.id', name VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0 COMMENT '표시 순서', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_account_tag_member_name (member_id, name), KEY idx_account_tag_member (member_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +ALTER TABLE account_tag ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; -- 정기/반복 거래 규칙 (사용자별) CREATE TABLE IF NOT EXISTS recurring ( diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql index deff0dc..d91f512 100644 --- a/src/main/resources/db/board.sql +++ b/src/main/resources/db/board.sql @@ -13,15 +13,19 @@ CREATE TABLE IF NOT EXISTS post ( view_count INT NOT NULL DEFAULT 0, blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '관리자 열람 제한 여부', block_reason VARCHAR(255) NULL COMMENT '제한 사유', + category VARCHAR(30) NOT NULL DEFAULT 'community' COMMENT '게시판 구분: community/saving/tips', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), - KEY idx_post_created (created_at) + KEY idx_post_created (created_at), + KEY idx_post_category (category, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 기존 post 테이블에 컬럼 추가 (멱등) ALTER TABLE post ADD COLUMN IF NOT EXISTS blocked TINYINT(1) NOT NULL DEFAULT 0; ALTER TABLE post ADD COLUMN IF NOT EXISTS block_reason VARCHAR(255) NULL; +-- 게시판 구분(커뮤니티/짠테크 수다방/재테크 팁). 기존 글은 community 로 이전 (멱등) +ALTER TABLE post ADD COLUMN IF NOT EXISTS category VARCHAR(30) NOT NULL DEFAULT 'community'; -- 큰 이미지(base64 인라인) 수용을 위해 content 를 LONGTEXT 로 확대 (멱등) ALTER TABLE post MODIFY content LONGTEXT NOT NULL; diff --git a/src/main/resources/mapper/AccountTagMapper.xml b/src/main/resources/mapper/AccountTagMapper.xml index b8c6421..c34932b 100644 --- a/src/main/resources/mapper/AccountTagMapper.xml +++ b/src/main/resources/mapper/AccountTagMapper.xml @@ -7,18 +7,19 @@ + @@ -31,8 +32,8 @@ - INSERT INTO account_tag (member_id, name, created_at) - VALUES (#{memberId}, #{name}, NOW()) + INSERT INTO account_tag (member_id, name, sort_order, created_at) + VALUES (#{memberId}, #{name}, COALESCE(#{sortOrder}, 0), NOW()) @@ -40,6 +41,15 @@ WHERE id = #{id} AND member_id = #{memberId} + + UPDATE account_tag SET sort_order = #{sortOrder} + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM account_tag WHERE id = #{id} AND member_id = #{memberId} diff --git a/src/main/resources/mapper/PostMapper.xml b/src/main/resources/mapper/PostMapper.xml index 22d6f73..0d8e212 100644 --- a/src/main/resources/mapper/PostMapper.xml +++ b/src/main/resources/mapper/PostMapper.xml @@ -9,8 +9,11 @@ JOIN tag t ON t.id = pt.tag_id + + AND p.category = #{category} + - t.name = #{tag} + AND t.name = #{tag} @@ -48,15 +51,15 @@ - INSERT INTO post (title, content, author_id, author_name, view_count, created_at, updated_at) - VALUES (#{title}, #{content}, #{authorId}, #{authorName}, 0, NOW(), NOW()) + INSERT INTO post (title, content, author_id, author_name, view_count, category, created_at, updated_at) + VALUES (#{title}, #{content}, #{authorId}, #{authorName}, 0, #{category}, NOW(), NOW()) diff --git a/src/main/resources/mapper/WalletMapper.xml b/src/main/resources/mapper/WalletMapper.xml index 7d738f9..609f25d 100644 --- a/src/main/resources/mapper/WalletMapper.xml +++ b/src/main/resources/mapper/WalletMapper.xml @@ -9,7 +9,8 @@ - + @@ -37,14 +38,16 @@ useGeneratedKeys="true" keyProperty="id"> INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type, opening_balance, opening_date, current_value, sort_order, created_at, updated_at) - VALUES (#{memberId}, #{type}, #{name}, #{issuer}, #{accountNumber}, #{cardType}, + VALUES (#{memberId}, #{type}, #{name}, #{issuer}, + #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}, #{cardType}, #{openingBalance}, #{openingDate}, #{currentValue}, #{sortOrder}, NOW(), NOW()) UPDATE wallet SET type = #{type}, name = #{name}, issuer = #{issuer}, - account_number = #{accountNumber}, card_type = #{cardType}, + account_number = #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}, + card_type = #{cardType}, opening_balance = #{openingBalance}, opening_date = #{openingDate}, current_value = #{currentValue} WHERE id = #{id} AND member_id = #{memberId}