Merge branch 'dev'
This commit is contained in:
@@ -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<AccountTagResponse> reorderTags(
|
||||
@RequestBody TagReorderRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.reorderTags(current.getId(), req.getIds());
|
||||
}
|
||||
|
||||
@GetMapping("/entries")
|
||||
public List<AccountEntryResponse> list(
|
||||
@RequestParam(required = false) Integer year,
|
||||
|
||||
@@ -46,6 +46,22 @@ public class InvestController {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(investService.createHolding(req, current.getId()));
|
||||
}
|
||||
|
||||
/** 종목코드로 현재가 시세를 일괄 갱신 */
|
||||
@PostMapping("/holdings/refresh-prices")
|
||||
public List<HoldingResponse> refreshPrices(
|
||||
@RequestParam Long walletId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return investService.refreshPrices(current.getId(), walletId);
|
||||
}
|
||||
|
||||
/** 모든 투자계좌 보유종목 현재가 일괄 갱신(계좌 목록 평가액 반영용) */
|
||||
@PostMapping("/refresh-prices")
|
||||
public ResponseEntity<Void> refreshAllPrices(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
investService.refreshAllPrices(current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/holdings/{id}")
|
||||
public HoldingResponse updateHolding(
|
||||
@PathVariable Long id,
|
||||
|
||||
@@ -20,5 +20,6 @@ public class AccountTag implements Serializable {
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String name;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -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<Long> ids; // 새 순서의 태그 id 목록
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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만 반환 */
|
||||
|
||||
@@ -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<AccountTagResponse> reorderTags(Long memberId, List<Long> 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);
|
||||
|
||||
@@ -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<HoldingResponse> refreshPrices(Long memberId, Long walletId) {
|
||||
requireInvestWallet(walletId, memberId);
|
||||
Map<String, Long> cache = new HashMap<>();
|
||||
for (InvestHolding h : investMapper.findHoldingsByWallet(memberId, walletId)) {
|
||||
applyQuote(h, cache);
|
||||
}
|
||||
return listHoldings(memberId, walletId);
|
||||
}
|
||||
|
||||
/** 회원의 모든 투자계좌 보유종목 현재가를 일괄 갱신(계좌 목록 평가액 반영용) */
|
||||
@Transactional
|
||||
public void refreshAllPrices(Long memberId) {
|
||||
Map<String, Long> cache = new HashMap<>();
|
||||
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
|
||||
applyQuote(h, cache);
|
||||
}
|
||||
}
|
||||
|
||||
/** 종목코드로 시세를 받아 현재가 갱신(동일 종목코드는 호출당 1회만 조회) */
|
||||
private void applyQuote(InvestHolding h, Map<String, Long> 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<TradeResponse> listTrades(Long holdingId, Long memberId) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,11 @@ public class BoardController {
|
||||
public PageResponse<PostSummary> 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}")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String> 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())
|
||||
|
||||
@@ -19,6 +19,9 @@ public class PostRequest {
|
||||
@NotBlank(message = "내용을 입력하세요.")
|
||||
private String content;
|
||||
|
||||
/** 게시판 구분: community/saving/tips. 없으면 community */
|
||||
private String category;
|
||||
|
||||
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ public interface PostMapper {
|
||||
|
||||
List<PostSummary> 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);
|
||||
|
||||
|
||||
@@ -40,15 +40,16 @@ public class BoardService {
|
||||
|
||||
/* ===================== 게시글 ===================== */
|
||||
|
||||
public PageResponse<PostSummary> list(int page, int size, String tag, String keyword, String searchType) {
|
||||
public PageResponse<PostSummary> 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<PostSummary> content = postMapper.findPage(offset, s, blankToNull(tag), blankToNull(keyword), st);
|
||||
long total = postMapper.countPage(blankToNull(tag), blankToNull(keyword), st);
|
||||
List<PostSummary> 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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 — 컬럼 저장 시 암호화, 조회 시 복호화.
|
||||
* 매퍼에서 컬럼별로 명시적으로 지정해 사용한다(전역 등록 금지).
|
||||
*
|
||||
* <pre>
|
||||
* resultMap: <result property="accountNumber" column="account_number"
|
||||
* typeHandler="com.sb.web.common.crypto.EncryptedStringTypeHandler"/>
|
||||
* insert/update: #{accountNumber, typeHandler=com.sb.web.common.crypto.EncryptedStringTypeHandler}
|
||||
* </pre>
|
||||
*/
|
||||
public class EncryptedStringTypeHandler extends BaseTypeHandler<String> {
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>저장 형식: {@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
<id property="id" column="id"/>
|
||||
<result property="memberId" column="member_id"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="sortOrder" column="sort_order"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByMember" parameterType="long" resultMap="tagResultMap">
|
||||
SELECT id, member_id, name, created_at
|
||||
SELECT id, member_id, name, sort_order, created_at
|
||||
FROM account_tag
|
||||
WHERE member_id = #{memberId}
|
||||
ORDER BY name
|
||||
ORDER BY sort_order, name
|
||||
</select>
|
||||
|
||||
<select id="findByIdAndMember" resultMap="tagResultMap">
|
||||
SELECT id, member_id, name, created_at
|
||||
SELECT id, member_id, name, sort_order, created_at
|
||||
FROM account_tag
|
||||
WHERE id = #{id} AND member_id = #{memberId}
|
||||
</select>
|
||||
@@ -31,8 +32,8 @@
|
||||
|
||||
<insert id="insert" parameterType="com.sb.web.account.domain.AccountTag"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
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())
|
||||
</insert>
|
||||
|
||||
<update id="update" parameterType="com.sb.web.account.domain.AccountTag">
|
||||
@@ -40,6 +41,15 @@
|
||||
WHERE id = #{id} AND member_id = #{memberId}
|
||||
</update>
|
||||
|
||||
<update id="updateSortOrder">
|
||||
UPDATE account_tag SET sort_order = #{sortOrder}
|
||||
WHERE id = #{id} AND member_id = #{memberId}
|
||||
</update>
|
||||
|
||||
<select id="maxSortOrder" resultType="java.lang.Integer">
|
||||
SELECT MAX(sort_order) FROM account_tag WHERE member_id = #{memberId}
|
||||
</select>
|
||||
|
||||
<delete id="delete">
|
||||
DELETE FROM account_tag WHERE id = #{id} AND member_id = #{memberId}
|
||||
</delete>
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
JOIN tag t ON t.id = pt.tag_id
|
||||
</if>
|
||||
<where>
|
||||
<if test="category != null and category != ''">
|
||||
AND p.category = #{category}
|
||||
</if>
|
||||
<if test="tag != null and tag != ''">
|
||||
t.name = #{tag}
|
||||
AND t.name = #{tag}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
<choose>
|
||||
@@ -48,15 +51,15 @@
|
||||
|
||||
<select id="findById" parameterType="long" resultType="com.sb.web.board.domain.Post">
|
||||
SELECT id, title, content, author_id, author_name, view_count,
|
||||
blocked, block_reason, created_at, updated_at
|
||||
blocked, block_reason, category, created_at, updated_at
|
||||
FROM post
|
||||
WHERE id = #{id}
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="com.sb.web.board.domain.Post"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
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())
|
||||
</insert>
|
||||
|
||||
<update id="update" parameterType="com.sb.web.board.domain.Post">
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
<result property="type" column="type"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="issuer" column="issuer"/>
|
||||
<result property="accountNumber" column="account_number"/>
|
||||
<result property="accountNumber" column="account_number"
|
||||
typeHandler="com.sb.web.common.crypto.EncryptedStringTypeHandler"/>
|
||||
<result property="cardType" column="card_type"/>
|
||||
<result property="openingBalance" column="opening_balance"/>
|
||||
<result property="openingDate" column="opening_date"/>
|
||||
@@ -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())
|
||||
</insert>
|
||||
|
||||
<update id="update" parameterType="com.sb.web.account.domain.Wallet">
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user