Compare commits
4 Commits
8fec057752
...
e592c174bf
| Author | SHA1 | Date | |
|---|---|---|---|
| e592c174bf | |||
| 5e2bdae37d | |||
| c05be0880c | |||
| fb94df8112 |
@@ -47,11 +47,21 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
|
# clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트)
|
||||||
- name: Build & Test
|
- name: Build & Test
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew --no-daemon clean build
|
./gradlew --no-daemon clean build
|
||||||
|
|
||||||
|
# 테스트 결과 리포트 — 성공/실패 무관하게 항상 업로드(실패 원인 확인용)
|
||||||
|
- name: Upload test report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: test-report
|
||||||
|
path: build/reports/tests/test
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload jar
|
- name: Upload jar
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.sb.web;
|
|||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper"})
|
@EnableScheduling
|
||||||
|
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper"})
|
||||||
public class SbBtApplication {
|
public class SbBtApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.sb.web.account.dto.AccountTagResponse;
|
|||||||
import com.sb.web.account.dto.CategoryStat;
|
import com.sb.web.account.dto.CategoryStat;
|
||||||
import com.sb.web.account.dto.ConfirmRequest;
|
import com.sb.web.account.dto.ConfirmRequest;
|
||||||
import com.sb.web.account.dto.NotificationRequest;
|
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.NetWorthPoint;
|
||||||
import com.sb.web.account.dto.NetWorthResponse;
|
import com.sb.web.account.dto.NetWorthResponse;
|
||||||
import com.sb.web.account.dto.PeriodStat;
|
import com.sb.web.account.dto.PeriodStat;
|
||||||
@@ -135,6 +136,14 @@ public class AccountController {
|
|||||||
return ResponseEntity.noContent().build();
|
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")
|
@GetMapping("/entries")
|
||||||
public List<AccountEntryResponse> list(
|
public List<AccountEntryResponse> list(
|
||||||
@RequestParam(required = false) Integer year,
|
@RequestParam(required = false) Integer year,
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ public class InvestController {
|
|||||||
return ResponseEntity.status(HttpStatus.CREATED).body(investService.createHolding(req, current.getId()));
|
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}")
|
@PutMapping("/holdings/{id}")
|
||||||
public HoldingResponse updateHolding(
|
public HoldingResponse updateHolding(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ public class AccountTag implements Serializable {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Long memberId;
|
private Long memberId;
|
||||||
private String name;
|
private String name;
|
||||||
|
private Integer sortOrder;
|
||||||
private LocalDateTime createdAt;
|
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 deposit; // 예수금(미투자 현금) = 투입원금 + 매도 − 매수
|
||||||
private Long stockValue; // 주식 평가금액
|
private Long stockValue; // 주식 평가금액
|
||||||
private Long valuationGain; // 총손익(실현+평가) = 총평가 − 투입원금
|
private Long valuationGain; // 총손익(실현+평가) = 총평가 − 투입원금
|
||||||
|
private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용
|
||||||
|
private Boolean manualValuation; // true면 평가액 직접입력형(종목 자동계산 대신)
|
||||||
|
|
||||||
public static WalletResponse from(Wallet w, long balance) {
|
public static WalletResponse from(Wallet w, long balance) {
|
||||||
return WalletResponse.builder()
|
return WalletResponse.builder()
|
||||||
@@ -40,6 +42,7 @@ public class WalletResponse {
|
|||||||
.openingBalance(w.getOpeningBalance())
|
.openingBalance(w.getOpeningBalance())
|
||||||
.openingDate(w.getOpeningDate())
|
.openingDate(w.getOpeningDate())
|
||||||
.balance(balance)
|
.balance(balance)
|
||||||
|
.currentValue(w.getCurrentValue())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ public interface AccountTagMapper {
|
|||||||
|
|
||||||
int update(AccountTag tag);
|
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);
|
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
/** 주어진 ID 중 해당 사용자 소유로 실제 존재하는 태그 ID만 반환 */
|
/** 주어진 ID 중 해당 사용자 소유로 실제 존재하는 태그 ID만 반환 */
|
||||||
|
|||||||
@@ -260,16 +260,27 @@ public class AccountService {
|
|||||||
return toResponse(mapper.findByIdAndMember(id, memberId));
|
return toResponse(mapper.findByIdAndMember(id, memberId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 부분일치) */
|
/** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 양방향 부분일치). 미매칭이면 카드가 1개뿐일 때 그 카드로. */
|
||||||
private Long matchCardWalletId(String issuer, Long memberId) {
|
private Long matchCardWalletId(String issuer, Long memberId) {
|
||||||
if (issuer == null) return null;
|
List<Wallet> cards = walletMapper.findByMember(memberId).stream()
|
||||||
return walletMapper.findByMember(memberId).stream()
|
|
||||||
.filter(w -> "CARD".equals(w.getType()))
|
.filter(w -> "CARD".equals(w.getType()))
|
||||||
.filter(w -> (w.getIssuer() != null && w.getIssuer().contains(issuer))
|
.toList();
|
||||||
|| (w.getName() != null && w.getName().contains(issuer)))
|
if (issuer != null) {
|
||||||
|
Long matched = cards.stream()
|
||||||
|
.filter(w -> overlaps(w.getIssuer(), issuer) || overlaps(w.getName(), issuer))
|
||||||
.map(Wallet::getId)
|
.map(Wallet::getId)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
if (matched != null) return matched;
|
||||||
|
}
|
||||||
|
// 카드사 미감지/미매칭이라도 등록된 카드가 정확히 1개면 그 카드로 기본 매칭
|
||||||
|
return cards.size() == 1 ? cards.get(0).getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 양방향 부분일치 (KB국민 ↔ 국민 등) */
|
||||||
|
private boolean overlaps(String walletField, String issuer) {
|
||||||
|
if (walletField == null || issuer == null) return false;
|
||||||
|
return walletField.contains(issuer) || issuer.contains(walletField);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== 상환/납부 ===================== */
|
/* ===================== 상환/납부 ===================== */
|
||||||
@@ -332,12 +343,24 @@ public class AccountService {
|
|||||||
if ("INVEST".equals(w.getType())) {
|
if ("INVEST".equals(w.getType())) {
|
||||||
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
||||||
long deposit = cash + wi.cashDelta; // 예수금
|
long deposit = cash + wi.cashDelta; // 예수금
|
||||||
|
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; // 총평가
|
long total = deposit + wi.stockEval; // 총평가
|
||||||
r.setBalance(total);
|
r.setBalance(total);
|
||||||
r.setInvestedAmount(cash); // 순입금(투입원금)
|
r.setInvestedAmount(cash); // 순입금(투입원금)
|
||||||
r.setDeposit(deposit);
|
r.setDeposit(deposit);
|
||||||
r.setStockValue(wi.stockEval);
|
r.setStockValue(wi.stockEval);
|
||||||
r.setValuationGain(total - cash); // 실현+평가 손익
|
r.setValuationGain(total - cash); // 실현+평가 손익
|
||||||
|
r.setManualValuation(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
})
|
})
|
||||||
@@ -353,9 +376,14 @@ public class AccountService {
|
|||||||
for (Wallet w : walletMapper.findByMember(memberId)) {
|
for (Wallet w : walletMapper.findByMember(memberId)) {
|
||||||
long bal = balances.getOrDefault(w.getId(), 0L);
|
long bal = balances.getOrDefault(w.getId(), 0L);
|
||||||
if ("INVEST".equals(w.getType())) {
|
if ("INVEST".equals(w.getType())) {
|
||||||
|
if (w.getCurrentValue() != null) {
|
||||||
|
// 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 자산으로
|
||||||
|
assets += w.getCurrentValue();
|
||||||
|
} else {
|
||||||
// 투자: 예수금(현금+매매증감) + 주식 평가금액
|
// 투자: 예수금(현금+매매증감) + 주식 평가금액
|
||||||
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
|
||||||
assets += bal + wi.cashDelta + wi.stockEval;
|
assets += bal + wi.cashDelta + wi.stockEval;
|
||||||
|
}
|
||||||
} else if ("BANK".equals(w.getType())) {
|
} else if ("BANK".equals(w.getType())) {
|
||||||
assets += bal;
|
assets += bal;
|
||||||
} else {
|
} else {
|
||||||
@@ -490,11 +518,28 @@ public class AccountService {
|
|||||||
if (tagMapper.countByName(memberId, name, null) > 0) {
|
if (tagMapper.countByName(memberId, name, null) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
|
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);
|
tagMapper.insert(tag);
|
||||||
return AccountTagResponse.from(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
|
@Transactional
|
||||||
public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) {
|
public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) {
|
||||||
AccountTag tag = mustFindTag(id, memberId);
|
AccountTag tag = mustFindTag(id, memberId);
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ public class CardNotificationParser {
|
|||||||
private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원");
|
private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원");
|
||||||
private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})");
|
private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})");
|
||||||
|
|
||||||
|
// 광고/이벤트 푸시에 흔한 표현 — 강한 승인신호(승인/출금)가 없으면 결제로 보지 않는다.
|
||||||
|
private static final Pattern AD_HINT = Pattern.compile(
|
||||||
|
"이벤트|광고|혜택|쿠폰|할인|적립|포인트|캐시백|마일리지|리워드|당첨|응모|추첨|프로모션|배너|무료|증정|모집");
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Builder
|
@Builder
|
||||||
public static class Parsed {
|
public static class Parsed {
|
||||||
@@ -49,6 +53,11 @@ public class CardNotificationParser {
|
|||||||
if (!am.find() || (!approved && !canceled)) {
|
if (!am.find() || (!approved && !canceled)) {
|
||||||
return notCard();
|
return notCard();
|
||||||
}
|
}
|
||||||
|
// 광고/이벤트 차단: 강한 승인신호(승인/출금/취소/환불)가 없는데 광고성 표현이 있으면 결제 아님
|
||||||
|
boolean strongSignal = raw.contains("승인") || raw.contains("출금") || canceled;
|
||||||
|
if (!strongSignal && AD_HINT.matcher(raw).find()) {
|
||||||
|
return notCard();
|
||||||
|
}
|
||||||
long amount = Long.parseLong(am.group(1).replace(",", ""));
|
long amount = Long.parseLong(am.group(1).replace(",", ""));
|
||||||
|
|
||||||
String issuer = detectIssuer(raw);
|
String issuer = detectIssuer(raw);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public class InvestService {
|
|||||||
|
|
||||||
private final InvestMapper investMapper;
|
private final InvestMapper investMapper;
|
||||||
private final WalletMapper walletMapper;
|
private final WalletMapper walletMapper;
|
||||||
|
private final StockQuoteService stockQuoteService;
|
||||||
|
|
||||||
/* ===================== 보유 종목 ===================== */
|
/* ===================== 보유 종목 ===================== */
|
||||||
|
|
||||||
@@ -73,6 +74,37 @@ public class InvestService {
|
|||||||
investMapper.deleteHolding(id, memberId);
|
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) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.sb.web.admin.controller;
|
||||||
|
|
||||||
|
import com.sb.web.admin.dto.SignupSettingRequest;
|
||||||
|
import com.sb.web.admin.service.AppSettingService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자용 앱 설정 API. (/api/admin — AdminInterceptor 로 관리자만 접근)
|
||||||
|
* GET /signup-setting 회원가입 허용 여부 조회
|
||||||
|
* PUT /signup-setting 회원가입 허용 여부 변경
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminSettingsController {
|
||||||
|
|
||||||
|
private final AppSettingService appSettingService;
|
||||||
|
|
||||||
|
@GetMapping("/signup-setting")
|
||||||
|
public Map<String, Boolean> getSignupSetting() {
|
||||||
|
return Map.of("enabled", appSettingService.isSignupEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/signup-setting")
|
||||||
|
public Map<String, Boolean> updateSignupSetting(@RequestBody SignupSettingRequest req) {
|
||||||
|
return Map.of("enabled", appSettingService.setSignupEnabled(req.isEnabled()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.sb.web.admin.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 허용 설정 변경 요청.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SignupSettingRequest {
|
||||||
|
|
||||||
|
private boolean enabled;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.sb.web.admin.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱 전역 설정(app_setting, 단일 행 id=1) 접근.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AppSettingMapper {
|
||||||
|
|
||||||
|
/** 회원가입 허용 여부 (true=허용) */
|
||||||
|
Boolean isSignupEnabled();
|
||||||
|
|
||||||
|
int updateSignupEnabled(@Param("enabled") boolean enabled);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.sb.web.admin.service;
|
||||||
|
|
||||||
|
import com.sb.web.admin.mapper.AppSettingMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱 전역 설정 서비스. 현재는 회원가입 허용 여부.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppSettingService {
|
||||||
|
|
||||||
|
private final AppSettingMapper appSettingMapper;
|
||||||
|
|
||||||
|
/** 회원가입 허용 여부. 설정 행이 없으면 기본 허용(true). */
|
||||||
|
public boolean isSignupEnabled() {
|
||||||
|
Boolean v = appSettingMapper.isSignupEnabled();
|
||||||
|
return v == null || v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean setSignupEnabled(boolean enabled) {
|
||||||
|
appSettingMapper.updateSignupEnabled(enabled);
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,10 +28,27 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
||||||
|
|
||||||
|
/** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */
|
||||||
|
@GetMapping("/signup-enabled")
|
||||||
|
public java.util.Map<String, Boolean> signupEnabled() {
|
||||||
|
return java.util.Map.of("enabled", appSettingService.isSignupEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/signup")
|
@PostMapping("/signup")
|
||||||
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody SignupRequest req) {
|
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody SignupRequest req,
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req));
|
HttpServletRequest request) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req, clientIp(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 클라이언트 IP (nginx 프록시 뒤 → X-Forwarded-For 우선) */
|
||||||
|
private String clientIp(HttpServletRequest request) {
|
||||||
|
String xff = request.getHeader("X-Forwarded-For");
|
||||||
|
if (xff != null && !xff.isBlank()) {
|
||||||
|
return xff.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -57,4 +74,22 @@ public class AuthController {
|
|||||||
authService.changePassword(current.getId(), req);
|
authService.changePassword(current.getId(), req);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 가입정보 변경 진입 전 비밀번호 재인증 */
|
||||||
|
@PostMapping("/verify-password")
|
||||||
|
public ResponseEntity<Void> verifyPassword(
|
||||||
|
@Valid @RequestBody com.sb.web.auth.dto.PasswordVerifyRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
authService.verifyPassword(current.getId(), req.getPassword());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 가입정보(이름/이메일) 변경 */
|
||||||
|
@PutMapping("/profile")
|
||||||
|
public MemberResponse updateProfile(
|
||||||
|
@Valid @RequestBody com.sb.web.auth.dto.ProfileUpdateRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.sb.web.auth.domain;
|
||||||
|
|
||||||
|
import com.sb.web.auth.dto.SessionUser;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 영속 백업(auth_session). Redis 유실 시 여기서 SessionUser 를 복원한다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AuthSession {
|
||||||
|
|
||||||
|
private String token;
|
||||||
|
private Long memberId;
|
||||||
|
private String loginId;
|
||||||
|
private String name;
|
||||||
|
private String role;
|
||||||
|
private String provider;
|
||||||
|
private boolean rememberMe;
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public static AuthSession of(String token, SessionUser u, LocalDateTime expiresAt) {
|
||||||
|
return AuthSession.builder()
|
||||||
|
.token(token)
|
||||||
|
.memberId(u.getId())
|
||||||
|
.loginId(u.getLoginId())
|
||||||
|
.name(u.getName())
|
||||||
|
.role(u.getRole())
|
||||||
|
.provider(u.getProvider())
|
||||||
|
.rememberMe(u.isRememberMe())
|
||||||
|
.expiresAt(expiresAt)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionUser toSessionUser() {
|
||||||
|
return SessionUser.builder()
|
||||||
|
.id(memberId)
|
||||||
|
.loginId(loginId)
|
||||||
|
.name(name)
|
||||||
|
.role(role)
|
||||||
|
.provider(provider)
|
||||||
|
.rememberMe(rememberMe)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.sb.web.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재인증 요청. 가입정보 변경 화면 진입 전 본인 확인용.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PasswordVerifyRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "비밀번호를 입력하세요.")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.sb.web.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입정보(프로필) 변경 요청. 비밀번호 인증을 통과한 뒤 호출된다.
|
||||||
|
* 변경 대상: 이름, 이메일 (아이디/가입유형은 변경 불가).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ProfileUpdateRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "이름을 입력하세요.")
|
||||||
|
@Size(max = 100, message = "이름은 100자 이하여야 합니다.")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Email(message = "이메일 형식이 올바르지 않습니다.")
|
||||||
|
@Size(max = 255, message = "이메일은 255자 이하여야 합니다.")
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@@ -24,4 +24,7 @@ public class SignupRequest {
|
|||||||
|
|
||||||
@Email(message = "이메일 형식이 올바르지 않습니다.")
|
@Email(message = "이메일 형식이 올바르지 않습니다.")
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
|
/** 허니팟 — 정상 사용자에겐 숨겨진 필드. 값이 있으면 봇으로 간주 */
|
||||||
|
private String website;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.sb.web.auth.mapper;
|
||||||
|
|
||||||
|
import com.sb.web.auth.domain.AuthSession;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 영속 백업(auth_session) 접근.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AuthSessionMapper {
|
||||||
|
|
||||||
|
int insert(AuthSession session);
|
||||||
|
|
||||||
|
AuthSession findByToken(@Param("token") String token);
|
||||||
|
|
||||||
|
int updateExpiry(@Param("token") String token, @Param("expiresAt") LocalDateTime expiresAt);
|
||||||
|
|
||||||
|
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
|
||||||
|
int updateName(@Param("token") String token, @Param("name") String name);
|
||||||
|
|
||||||
|
int delete(@Param("token") String token);
|
||||||
|
|
||||||
|
int deleteExpired(@Param("now") LocalDateTime now);
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ public interface MemberMapper {
|
|||||||
|
|
||||||
int updatePassword(@Param("id") Long id, @Param("password") String password);
|
int updatePassword(@Param("id") Long id, @Param("password") String password);
|
||||||
|
|
||||||
|
/** 프로필(이름/이메일) 변경 */
|
||||||
|
int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email);
|
||||||
|
|
||||||
int updateRole(@Param("id") Long id, @Param("role") String role);
|
int updateRole(@Param("id") Long id, @Param("role") String role);
|
||||||
|
|
||||||
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.sb.web.auth.service;
|
package com.sb.web.auth.service;
|
||||||
|
|
||||||
|
import com.sb.web.auth.domain.AuthSession;
|
||||||
import com.sb.web.auth.domain.Member;
|
import com.sb.web.auth.domain.Member;
|
||||||
import com.sb.web.auth.dto.LoginRequest;
|
import com.sb.web.auth.dto.LoginRequest;
|
||||||
import com.sb.web.auth.dto.LoginResponse;
|
import com.sb.web.auth.dto.LoginResponse;
|
||||||
@@ -34,13 +35,29 @@ public class AuthService {
|
|||||||
private final MemberMapper memberMapper;
|
private final MemberMapper memberMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final RedisTemplate<String, Object> redisTemplate;
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
||||||
|
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
|
||||||
|
|
||||||
private static final String SESSION_PREFIX = "session:";
|
private static final String SESSION_PREFIX = "session:";
|
||||||
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
||||||
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
|
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
|
||||||
|
|
||||||
|
// 회원가입 봇 방지 — IP당 가입 시도 제한(슬라이딩 윈도우)
|
||||||
|
private static final int SIGNUP_LIMIT = 5;
|
||||||
|
private static final Duration SIGNUP_WINDOW = Duration.ofHours(1);
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MemberResponse signup(SignupRequest req) {
|
public MemberResponse signup(SignupRequest req, String clientIp) {
|
||||||
|
if (!appSettingService.isSignupEnabled()) {
|
||||||
|
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
|
||||||
|
}
|
||||||
|
// 1) 허니팟: 숨김 필드에 값이 차 있으면 봇 → 조용히 차단
|
||||||
|
if (req.getWebsite() != null && !req.getWebsite().isBlank()) {
|
||||||
|
log.warn("[signup] honeypot 차단 ip={}", clientIp);
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.");
|
||||||
|
}
|
||||||
|
// 2) IP 레이트리밋
|
||||||
|
rateLimitSignup(clientIp);
|
||||||
if (memberMapper.countByLoginId(req.getLoginId()) > 0) {
|
if (memberMapper.countByLoginId(req.getLoginId()) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
|
throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
|
||||||
}
|
}
|
||||||
@@ -58,6 +75,23 @@ public class AuthService {
|
|||||||
return MemberResponse.from(member);
|
return MemberResponse.from(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** IP당 가입 시도 횟수 제한 (Redis 슬라이딩 윈도우). 초과 시 429 */
|
||||||
|
private void rateLimitSignup(String ip) {
|
||||||
|
if (ip == null || ip.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String key = "signup:rl:" + ip;
|
||||||
|
Long count = redisTemplate.opsForValue().increment(key);
|
||||||
|
if (count != null && count == 1L) {
|
||||||
|
redisTemplate.expire(key, SIGNUP_WINDOW);
|
||||||
|
}
|
||||||
|
if (count != null && count > SIGNUP_LIMIT) {
|
||||||
|
log.warn("[signup] 레이트리밋 차단 ip={} count={}", ip, count);
|
||||||
|
throw new ApiException(HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
"회원가입 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public LoginResponse login(LoginRequest req) {
|
public LoginResponse login(LoginRequest req) {
|
||||||
Member member = memberMapper.findByLoginId(req.getLoginId());
|
Member member = memberMapper.findByLoginId(req.getLoginId());
|
||||||
if (member == null
|
if (member == null
|
||||||
@@ -74,7 +108,14 @@ public class AuthService {
|
|||||||
session.setRememberMe(req.isRememberMe());
|
session.setRememberMe(req.isRememberMe());
|
||||||
|
|
||||||
String token = UUID.randomUUID().toString().replace("-", "");
|
String token = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
|
||||||
|
try {
|
||||||
redisTemplate.opsForValue().set(SESSION_PREFIX + token, session, ttl);
|
redisTemplate.opsForValue().set(SESSION_PREFIX + token, session, ttl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
|
||||||
|
}
|
||||||
|
// 영속 백업 — Redis 재시작/유실에도 로그인 유지
|
||||||
|
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
|
||||||
log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe());
|
log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe());
|
||||||
|
|
||||||
return LoginResponse.builder()
|
return LoginResponse.builder()
|
||||||
@@ -86,24 +127,127 @@ public class AuthService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
||||||
|
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
||||||
*/
|
*/
|
||||||
public SessionUser getSession(String token) {
|
public SessionUser getSession(String token) {
|
||||||
if (token == null || token.isBlank()) {
|
if (token == null || token.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String key = SESSION_PREFIX + token;
|
String key = SESSION_PREFIX + token;
|
||||||
|
// 1) Redis 우선 (장애 시 예외는 무시하고 DB 백업으로)
|
||||||
|
try {
|
||||||
Object cached = redisTemplate.opsForValue().get(key);
|
Object cached = redisTemplate.opsForValue().get(key);
|
||||||
if (cached instanceof SessionUser user) {
|
if (cached instanceof SessionUser user) {
|
||||||
// 슬라이딩 만료: 자동 로그인 세션이면 길게(30일), 아니면 60분으로 갱신
|
|
||||||
redisTemplate.expire(key, user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
redisTemplate.expire(key, user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[session] Redis 조회 실패 → DB 백업 사용: {}", e.toString());
|
||||||
|
}
|
||||||
|
// 2) DB 백업에서 복원
|
||||||
|
AuthSession db = authSessionMapper.findByToken(token);
|
||||||
|
if (db == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (db.getExpiresAt() == null || db.getExpiresAt().isBefore(java.time.LocalDateTime.now())) {
|
||||||
|
authSessionMapper.delete(token); // 만료분 정리
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SessionUser user = db.toSessionUser();
|
||||||
|
Duration ttl = user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL;
|
||||||
|
try {
|
||||||
|
redisTemplate.opsForValue().set(key, user, ttl); // Redis 재수화
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// Redis 가 아직 불가해도 DB 로 동작
|
||||||
|
}
|
||||||
|
authSessionMapper.updateExpiry(token, java.time.LocalDateTime.now().plus(ttl)); // DB 슬라이딩
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
public void logout(String token) {
|
public void logout(String token) {
|
||||||
if (token != null && !token.isBlank()) {
|
if (token != null && !token.isBlank()) {
|
||||||
|
try {
|
||||||
redisTemplate.delete(SESSION_PREFIX + token);
|
redisTemplate.delete(SESSION_PREFIX + token);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// Redis 장애여도 DB 삭제는 진행
|
||||||
|
}
|
||||||
|
authSessionMapper.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 만료된 백업 세션 정리 (매일 새벽 4시) */
|
||||||
|
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
|
||||||
|
public void cleanupExpiredSessions() {
|
||||||
|
try {
|
||||||
|
int n = authSessionMapper.deleteExpired(java.time.LocalDateTime.now());
|
||||||
|
if (n > 0) log.info("[session] 만료 백업 세션 {}건 정리", n);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[session] 만료 세션 정리 실패: {}", e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재인증 (가입정보 변경 진입 전 본인 확인). 일치하지 않으면 401.
|
||||||
|
*/
|
||||||
|
public void verifyPassword(Long memberId, String password) {
|
||||||
|
Member member = memberMapper.findById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (!"LOCAL".equals(member.getProvider()) || member.getPassword() == null) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 비밀번호 인증을 사용할 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (password == null || !passwordEncoder.matches(password, member.getPassword())) {
|
||||||
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "비밀번호가 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다.
|
||||||
|
* 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MemberResponse updateProfile(Long memberId, String token, com.sb.web.auth.dto.ProfileUpdateRequest req) {
|
||||||
|
Member member = memberMapper.findById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
String name = req.getName() == null ? null : req.getName().trim();
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이름을 입력하세요.");
|
||||||
|
}
|
||||||
|
String email = (req.getEmail() == null || req.getEmail().isBlank()) ? null : req.getEmail().trim();
|
||||||
|
memberMapper.updateProfile(memberId, name, email);
|
||||||
|
member.setName(name);
|
||||||
|
member.setEmail(email);
|
||||||
|
syncSessionName(token, name);
|
||||||
|
log.info("[profile] updated for {} (name={})", member.getLoginId(), name);
|
||||||
|
return MemberResponse.from(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
|
||||||
|
private void syncSessionName(String token, String name) {
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String key = SESSION_PREFIX + token;
|
||||||
|
try {
|
||||||
|
Object cached = redisTemplate.opsForValue().get(key);
|
||||||
|
if (cached instanceof SessionUser u) {
|
||||||
|
u.setName(name);
|
||||||
|
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
Duration remain = (ttl != null && ttl > 0)
|
||||||
|
? Duration.ofSeconds(ttl)
|
||||||
|
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
||||||
|
redisTemplate.opsForValue().set(key, u, remain);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[profile] Redis 세션 이름 동기화 실패(무시): {}", e.toString());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
authSessionMapper.updateName(token, name);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,11 @@ public class BoardController {
|
|||||||
public PageResponse<PostSummary> list(
|
public PageResponse<PostSummary> list(
|
||||||
@RequestParam(defaultValue = "1") int page,
|
@RequestParam(defaultValue = "1") int page,
|
||||||
@RequestParam(defaultValue = "10") int size,
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
@RequestParam(required = false) String tag,
|
@RequestParam(required = false) String tag,
|
||||||
@RequestParam(required = false) String keyword,
|
@RequestParam(required = false) String keyword,
|
||||||
@RequestParam(required = false) String searchType) {
|
@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}")
|
@GetMapping("/posts/{id}")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class Post implements Serializable {
|
|||||||
private Integer viewCount;
|
private Integer viewCount;
|
||||||
private Boolean blocked;
|
private Boolean blocked;
|
||||||
private String blockReason;
|
private String blockReason;
|
||||||
|
private String category; // 게시판 구분: community/saving/tips
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class PostDetail {
|
|||||||
private Integer viewCount;
|
private Integer viewCount;
|
||||||
private Boolean blocked;
|
private Boolean blocked;
|
||||||
private String blockReason;
|
private String blockReason;
|
||||||
|
private String category;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
@@ -37,6 +38,7 @@ public class PostDetail {
|
|||||||
.viewCount(p.getViewCount())
|
.viewCount(p.getViewCount())
|
||||||
.blocked(Boolean.TRUE.equals(p.getBlocked()))
|
.blocked(Boolean.TRUE.equals(p.getBlocked()))
|
||||||
.blockReason(p.getBlockReason())
|
.blockReason(p.getBlockReason())
|
||||||
|
.category(p.getCategory())
|
||||||
.createdAt(p.getCreatedAt())
|
.createdAt(p.getCreatedAt())
|
||||||
.updatedAt(p.getUpdatedAt())
|
.updatedAt(p.getUpdatedAt())
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
@@ -52,6 +54,7 @@ public class PostDetail {
|
|||||||
.authorName(p.getAuthorName())
|
.authorName(p.getAuthorName())
|
||||||
.viewCount(p.getViewCount())
|
.viewCount(p.getViewCount())
|
||||||
.blocked(true)
|
.blocked(true)
|
||||||
|
.category(p.getCategory())
|
||||||
.createdAt(p.getCreatedAt())
|
.createdAt(p.getCreatedAt())
|
||||||
.tags(List.of())
|
.tags(List.of())
|
||||||
.comments(List.of())
|
.comments(List.of())
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ public class PostRequest {
|
|||||||
@NotBlank(message = "내용을 입력하세요.")
|
@NotBlank(message = "내용을 입력하세요.")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
/** 게시판 구분: community/saving/tips. 없으면 community */
|
||||||
|
private String category;
|
||||||
|
|
||||||
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
|
/** 선택한 태그 ID 목록 (DB에 등록된 태그만, 선택) */
|
||||||
private List<Long> tagIds;
|
private List<Long> tagIds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ public interface PostMapper {
|
|||||||
|
|
||||||
List<PostSummary> findPage(@Param("offset") int offset,
|
List<PostSummary> findPage(@Param("offset") int offset,
|
||||||
@Param("size") int size,
|
@Param("size") int size,
|
||||||
|
@Param("category") String category,
|
||||||
@Param("tag") String tag,
|
@Param("tag") String tag,
|
||||||
@Param("keyword") String keyword,
|
@Param("keyword") String keyword,
|
||||||
@Param("searchType") String searchType);
|
@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("keyword") String keyword,
|
||||||
@Param("searchType") String searchType);
|
@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 p = Math.max(page, 1);
|
||||||
int s = Math.min(Math.max(size, 1), 100);
|
int s = Math.min(Math.max(size, 1), 100);
|
||||||
int offset = (p - 1) * s;
|
int offset = (p - 1) * s;
|
||||||
String st = normalizeSearchType(searchType);
|
String st = normalizeSearchType(searchType);
|
||||||
|
String cat = normalizeCategory(category);
|
||||||
|
|
||||||
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
||||||
List<PostSummary> content = postMapper.findPage(offset, s, blankToNull(tag), blankToNull(keyword), st);
|
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st);
|
||||||
long total = postMapper.countPage(blankToNull(tag), blankToNull(keyword), st);
|
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st);
|
||||||
return PageResponse.of(content, p, s, total);
|
return PageResponse.of(content, p, s, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,18 @@ public class BoardService {
|
|||||||
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
|
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
|
@Transactional
|
||||||
public PostDetail get(Long id, SessionUser viewer) {
|
public PostDetail get(Long id, SessionUser viewer) {
|
||||||
@@ -95,6 +108,7 @@ public class BoardService {
|
|||||||
.content(req.getContent())
|
.content(req.getContent())
|
||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(author.getName())
|
.authorName(author.getName())
|
||||||
|
.category(toCategory(req.getCategory()))
|
||||||
.build();
|
.build();
|
||||||
postMapper.insert(post);
|
postMapper.insert(post);
|
||||||
applyTags(post.getId(), req.getTagIds());
|
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:
|
vision:
|
||||||
api-key: ${GOOGLE_VISION_API_KEY:}
|
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 =====
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS wallet (
|
|||||||
type VARCHAR(10) NOT NULL COMMENT 'BANK / CARD / LOAN / INVEST',
|
type VARCHAR(10) NOT NULL COMMENT 'BANK / CARD / LOAN / INVEST',
|
||||||
name VARCHAR(50) NOT NULL COMMENT '별칭',
|
name VARCHAR(50) NOT NULL COMMENT '별칭',
|
||||||
issuer VARCHAR(50) 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)',
|
card_type VARCHAR(10) NULL COMMENT 'CREDIT / CHECK (CARD)',
|
||||||
opening_balance BIGINT NOT NULL DEFAULT 0 COMMENT '초기 잔액(부호있음: 자산+, 부채-)',
|
opening_balance BIGINT NOT NULL DEFAULT 0 COMMENT '초기 잔액(부호있음: 자산+, 부채-)',
|
||||||
opening_date DATE NULL COMMENT '초기잔액 기준일',
|
opening_date DATE NULL COMMENT '초기잔액 기준일',
|
||||||
@@ -28,6 +28,9 @@ 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 opening_date DATE NULL;
|
||||||
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS current_value BIGINT 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;
|
ALTER TABLE wallet ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||||
|
-- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영).
|
||||||
|
-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용:
|
||||||
|
-- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS account_entry (
|
CREATE TABLE IF NOT EXISTS account_entry (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
@@ -62,11 +65,13 @@ CREATE TABLE IF NOT EXISTS account_tag (
|
|||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
|
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '표시 순서',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_account_tag_member_name (member_id, name),
|
UNIQUE KEY uk_account_tag_member_name (member_id, name),
|
||||||
KEY idx_account_tag_member (member_id)
|
KEY idx_account_tag_member (member_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS recurring (
|
||||||
|
|||||||
@@ -13,15 +13,19 @@ CREATE TABLE IF NOT EXISTS post (
|
|||||||
view_count INT NOT NULL DEFAULT 0,
|
view_count INT NOT NULL DEFAULT 0,
|
||||||
blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '관리자 열람 제한 여부',
|
blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '관리자 열람 제한 여부',
|
||||||
block_reason VARCHAR(255) NULL 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,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 기존 post 테이블에 컬럼 추가 (멱등)
|
-- 기존 post 테이블에 컬럼 추가 (멱등)
|
||||||
ALTER TABLE post ADD COLUMN IF NOT EXISTS blocked TINYINT(1) NOT NULL DEFAULT 0;
|
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;
|
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 로 확대 (멱등)
|
-- 큰 이미지(base64 인라인) 수용을 위해 content 를 LONGTEXT 로 확대 (멱등)
|
||||||
ALTER TABLE post MODIFY content LONGTEXT NOT NULL;
|
ALTER TABLE post MODIFY content LONGTEXT NOT NULL;
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,31 @@ CREATE TABLE IF NOT EXISTS member (
|
|||||||
UNIQUE KEY uk_member_login_id (login_id),
|
UNIQUE KEY uk_member_login_id (login_id),
|
||||||
UNIQUE KEY uk_member_provider (provider, provider_id)
|
UNIQUE KEY uk_member_provider (provider, provider_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS app_setting (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
signup_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '회원가입 허용 여부(0=제한)',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
INSERT IGNORE INTO app_setting (id, signup_enabled) VALUES (1, 1);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 세션 백업 (Redis 유실/재시작 대비 영속 저장). getSession 미스 시 DB에서 복원.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_session (
|
||||||
|
token VARCHAR(64) NOT NULL COMMENT '세션 토큰',
|
||||||
|
member_id BIGINT NOT NULL,
|
||||||
|
login_id VARCHAR(50) NULL,
|
||||||
|
name VARCHAR(100) NULL,
|
||||||
|
role VARCHAR(20) NULL,
|
||||||
|
provider VARCHAR(20) NULL,
|
||||||
|
remember_me TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (token),
|
||||||
|
KEY idx_auth_session_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
@@ -7,18 +7,19 @@
|
|||||||
<id property="id" column="id"/>
|
<id property="id" column="id"/>
|
||||||
<result property="memberId" column="member_id"/>
|
<result property="memberId" column="member_id"/>
|
||||||
<result property="name" column="name"/>
|
<result property="name" column="name"/>
|
||||||
|
<result property="sortOrder" column="sort_order"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByMember" parameterType="long" resultMap="tagResultMap">
|
<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
|
FROM account_tag
|
||||||
WHERE member_id = #{memberId}
|
WHERE member_id = #{memberId}
|
||||||
ORDER BY name
|
ORDER BY sort_order, name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findByIdAndMember" resultMap="tagResultMap">
|
<select id="findByIdAndMember" resultMap="tagResultMap">
|
||||||
SELECT id, member_id, name, created_at
|
SELECT id, member_id, name, sort_order, created_at
|
||||||
FROM account_tag
|
FROM account_tag
|
||||||
WHERE id = #{id} AND member_id = #{memberId}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</select>
|
</select>
|
||||||
@@ -31,8 +32,8 @@
|
|||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.account.domain.AccountTag"
|
<insert id="insert" parameterType="com.sb.web.account.domain.AccountTag"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO account_tag (member_id, name, created_at)
|
INSERT INTO account_tag (member_id, name, sort_order, created_at)
|
||||||
VALUES (#{memberId}, #{name}, NOW())
|
VALUES (#{memberId}, #{name}, COALESCE(#{sortOrder}, 0), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.AccountTag">
|
<update id="update" parameterType="com.sb.web.account.domain.AccountTag">
|
||||||
@@ -40,6 +41,15 @@
|
|||||||
WHERE id = #{id} AND member_id = #{memberId}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</update>
|
</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 id="delete">
|
||||||
DELETE FROM account_tag WHERE id = #{id} AND member_id = #{memberId}
|
DELETE FROM account_tag WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.admin.mapper.AppSettingMapper">
|
||||||
|
|
||||||
|
<select id="isSignupEnabled" resultType="java.lang.Boolean">
|
||||||
|
SELECT signup_enabled FROM app_setting WHERE id = 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="updateSignupEnabled">
|
||||||
|
UPDATE app_setting SET signup_enabled = #{enabled} WHERE id = 1
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.auth.mapper.AuthSessionMapper">
|
||||||
|
|
||||||
|
<insert id="insert" parameterType="com.sb.web.auth.domain.AuthSession">
|
||||||
|
INSERT INTO auth_session
|
||||||
|
(token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{provider},
|
||||||
|
#{rememberMe}, #{expiresAt}, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
expires_at = #{expiresAt}
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="findByToken" resultType="com.sb.web.auth.domain.AuthSession">
|
||||||
|
SELECT token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at
|
||||||
|
FROM auth_session
|
||||||
|
WHERE token = #{token}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="updateExpiry">
|
||||||
|
UPDATE auth_session SET expires_at = #{expiresAt} WHERE token = #{token}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="updateName">
|
||||||
|
UPDATE auth_session SET name = #{name} WHERE token = #{token}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="delete">
|
||||||
|
DELETE FROM auth_session WHERE token = #{token}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<delete id="deleteExpired">
|
||||||
|
DELETE FROM auth_session WHERE expires_at < #{now}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -54,6 +54,10 @@
|
|||||||
UPDATE member SET password = #{password}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET password = #{password}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateProfile">
|
||||||
|
UPDATE member SET name = #{name}, email = #{email}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<update id="updateRole">
|
<update id="updateRole">
|
||||||
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
JOIN tag t ON t.id = pt.tag_id
|
JOIN tag t ON t.id = pt.tag_id
|
||||||
</if>
|
</if>
|
||||||
<where>
|
<where>
|
||||||
|
<if test="category != null and category != ''">
|
||||||
|
AND p.category = #{category}
|
||||||
|
</if>
|
||||||
<if test="tag != null and tag != ''">
|
<if test="tag != null and tag != ''">
|
||||||
t.name = #{tag}
|
AND t.name = #{tag}
|
||||||
</if>
|
</if>
|
||||||
<if test="keyword != null and keyword != ''">
|
<if test="keyword != null and keyword != ''">
|
||||||
<choose>
|
<choose>
|
||||||
@@ -48,15 +51,15 @@
|
|||||||
|
|
||||||
<select id="findById" parameterType="long" resultType="com.sb.web.board.domain.Post">
|
<select id="findById" parameterType="long" resultType="com.sb.web.board.domain.Post">
|
||||||
SELECT id, title, content, author_id, author_name, view_count,
|
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
|
FROM post
|
||||||
WHERE id = #{id}
|
WHERE id = #{id}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.board.domain.Post"
|
<insert id="insert" parameterType="com.sb.web.board.domain.Post"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO post (title, content, author_id, author_name, view_count, created_at, updated_at)
|
INSERT INTO post (title, content, author_id, author_name, view_count, category, created_at, updated_at)
|
||||||
VALUES (#{title}, #{content}, #{authorId}, #{authorName}, 0, NOW(), NOW())
|
VALUES (#{title}, #{content}, #{authorId}, #{authorName}, 0, #{category}, NOW(), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.board.domain.Post">
|
<update id="update" parameterType="com.sb.web.board.domain.Post">
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
<result property="type" column="type"/>
|
<result property="type" column="type"/>
|
||||||
<result property="name" column="name"/>
|
<result property="name" column="name"/>
|
||||||
<result property="issuer" column="issuer"/>
|
<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="cardType" column="card_type"/>
|
||||||
<result property="openingBalance" column="opening_balance"/>
|
<result property="openingBalance" column="opening_balance"/>
|
||||||
<result property="openingDate" column="opening_date"/>
|
<result property="openingDate" column="opening_date"/>
|
||||||
@@ -37,14 +38,16 @@
|
|||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type,
|
INSERT INTO wallet (member_id, type, name, issuer, account_number, card_type,
|
||||||
opening_balance, opening_date, current_value, sort_order, created_at, updated_at)
|
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())
|
#{openingBalance}, #{openingDate}, #{currentValue}, #{sortOrder}, NOW(), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.Wallet">
|
<update id="update" parameterType="com.sb.web.account.domain.Wallet">
|
||||||
UPDATE wallet
|
UPDATE wallet
|
||||||
SET type = #{type}, name = #{name}, issuer = #{issuer},
|
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},
|
opening_balance = #{openingBalance}, opening_date = #{openingDate},
|
||||||
current_value = #{currentValue}
|
current_value = #{currentValue}
|
||||||
WHERE id = #{id} AND member_id = #{memberId}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 결제 알림 파서 단위테스트.
|
||||||
|
* 핵심: 정상 결제 인식 / 취소 인식 / 광고성 푸시 차단 / 강신호 우선.
|
||||||
|
*/
|
||||||
|
class CardNotificationParserTest {
|
||||||
|
|
||||||
|
private final CardNotificationParser parser = new CardNotificationParser();
|
||||||
|
private final LocalDate today = LocalDate.of(2026, 6, 6);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("정상 승인 결제는 카드로 인식하고 카드사·금액을 추출한다")
|
||||||
|
void parsesApprovedPayment() {
|
||||||
|
var p = parser.parse("[Web발신]", "삼성카드 승인 50,000원 일시불\n스타벅스 06/06 14:30", today);
|
||||||
|
|
||||||
|
assertThat(p.isCard()).isTrue();
|
||||||
|
assertThat(p.isCanceled()).isFalse();
|
||||||
|
assertThat(p.getIssuer()).isEqualTo("삼성");
|
||||||
|
assertThat(p.getAmount()).isEqualTo(50000L);
|
||||||
|
assertThat(p.getNotifKey()).isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("승인취소는 취소 결제로 인식한다")
|
||||||
|
void parsesCanceledPayment() {
|
||||||
|
var p = parser.parse("현대카드", "승인취소 12,000원", today);
|
||||||
|
|
||||||
|
assertThat(p.isCard()).isTrue();
|
||||||
|
assertThat(p.isCanceled()).isTrue();
|
||||||
|
assertThat(p.getIssuer()).isEqualTo("현대");
|
||||||
|
assertThat(p.getAmount()).isEqualTo(12000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("강한 승인신호 없이 광고성 표현만 있으면 결제로 보지 않는다")
|
||||||
|
void blocksAdvertisementPush() {
|
||||||
|
var p = parser.parse("카카오", "결제하고 혜택 받기! 10,000원 쿠폰 적립 이벤트", today);
|
||||||
|
|
||||||
|
assertThat(p.isCard()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("승인 신호가 있으면 광고 표현이 섞여도 결제로 인식한다")
|
||||||
|
void strongSignalOverridesAdWords() {
|
||||||
|
var p = parser.parse("삼성카드", "승인 30,000원 (이벤트 적립 대상)", today);
|
||||||
|
|
||||||
|
assertThat(p.isCard()).isTrue();
|
||||||
|
assertThat(p.getAmount()).isEqualTo(30000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("금액이 없으면 결제 알림이 아니다")
|
||||||
|
void requiresAmount() {
|
||||||
|
var p = parser.parse("삼성카드", "승인 완료되었습니다", today);
|
||||||
|
|
||||||
|
assertThat(p.isCard()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("승인/취소 키워드가 없으면 결제 알림이 아니다")
|
||||||
|
void requiresApprovalKeyword() {
|
||||||
|
var p = parser.parse("삼성카드", "50,000원", today);
|
||||||
|
|
||||||
|
assertThat(p.isCard()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("빈 알림은 결제가 아니다")
|
||||||
|
void blankIsNotCard() {
|
||||||
|
assertThat(parser.parse(null, null, today).isCard()).isFalse();
|
||||||
|
assertThat(parser.parse("", "", today).isCard()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM/DD 날짜를 올해 거래일로 추정한다")
|
||||||
|
void guessesDate() {
|
||||||
|
var p = parser.parse("신한카드", "승인 5,000원 6/6", today);
|
||||||
|
|
||||||
|
assertThat(p.isCard()).isTrue();
|
||||||
|
assertThat(p.getDate()).isEqualTo(LocalDate.of(2026, 6, 6));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package com.sb.web.auth.service;
|
||||||
|
|
||||||
|
import com.sb.web.admin.service.AppSettingService;
|
||||||
|
import com.sb.web.auth.domain.AuthSession;
|
||||||
|
import com.sb.web.auth.domain.Member;
|
||||||
|
import com.sb.web.auth.dto.LoginRequest;
|
||||||
|
import com.sb.web.auth.dto.LoginResponse;
|
||||||
|
import com.sb.web.auth.dto.MemberResponse;
|
||||||
|
import com.sb.web.auth.dto.ProfileUpdateRequest;
|
||||||
|
import com.sb.web.auth.dto.SessionUser;
|
||||||
|
import com.sb.web.auth.dto.SignupRequest;
|
||||||
|
import com.sb.web.auth.mapper.AuthSessionMapper;
|
||||||
|
import com.sb.web.auth.mapper.MemberMapper;
|
||||||
|
import com.sb.web.common.exception.ApiException;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthService 단위테스트 (Mockito).
|
||||||
|
* 인증 핵심: 비밀번호 재인증 / 가입정보 변경 / 회원가입 차단 / 로그인 / 세션 DB 백업 복원.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuthServiceTest {
|
||||||
|
|
||||||
|
@Mock MemberMapper memberMapper;
|
||||||
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
|
@Mock RedisTemplate<String, Object> redisTemplate;
|
||||||
|
@Mock ValueOperations<String, Object> valueOps;
|
||||||
|
@Mock AppSettingService appSettingService;
|
||||||
|
@Mock AuthSessionMapper authSessionMapper;
|
||||||
|
|
||||||
|
AuthService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Member localMember() {
|
||||||
|
return Member.builder()
|
||||||
|
.id(1L).loginId("u1").password("hash").name("원래이름")
|
||||||
|
.email("old@b.com").provider("LOCAL").role("USER").status("ACTIVE")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== verifyPassword =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("verifyPassword: 일치하면 예외 없음")
|
||||||
|
void verifyPassword_ok() {
|
||||||
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
||||||
|
when(passwordEncoder.matches("pw", "hash")).thenReturn(true);
|
||||||
|
|
||||||
|
service.verifyPassword(1L, "pw");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("verifyPassword: 불일치면 401")
|
||||||
|
void verifyPassword_wrong() {
|
||||||
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
||||||
|
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.verifyPassword(1L, "bad"))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("verifyPassword: 소셜 계정은 400")
|
||||||
|
void verifyPassword_social() {
|
||||||
|
Member social = Member.builder().id(1L).provider("NAVER").password(null).build();
|
||||||
|
when(memberMapper.findById(1L)).thenReturn(social);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.verifyPassword(1L, "pw"))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("verifyPassword: 회원 없으면 404")
|
||||||
|
void verifyPassword_notFound() {
|
||||||
|
when(memberMapper.findById(1L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.verifyPassword(1L, "pw"))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== updateProfile =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("updateProfile: 이름/이메일 변경 + 세션 이름 동기화")
|
||||||
|
void updateProfile_ok() {
|
||||||
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(valueOps.get("session:tok")).thenReturn(null); // Redis 세션 없음 → DB만 동기화
|
||||||
|
|
||||||
|
ProfileUpdateRequest req = new ProfileUpdateRequest();
|
||||||
|
req.setName(" 새이름 ");
|
||||||
|
req.setEmail("new@b.com");
|
||||||
|
|
||||||
|
MemberResponse res = service.updateProfile(1L, "tok", req);
|
||||||
|
|
||||||
|
assertThat(res.getName()).isEqualTo("새이름"); // trim
|
||||||
|
assertThat(res.getEmail()).isEqualTo("new@b.com");
|
||||||
|
verify(memberMapper).updateProfile(1L, "새이름", "new@b.com");
|
||||||
|
verify(authSessionMapper).updateName("tok", "새이름");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("updateProfile: 이름이 비면 400, 변경 안 함")
|
||||||
|
void updateProfile_blankName() {
|
||||||
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
||||||
|
|
||||||
|
ProfileUpdateRequest req = new ProfileUpdateRequest();
|
||||||
|
req.setName(" ");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.updateProfile(1L, "tok", req))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
verify(memberMapper, never()).updateProfile(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== signup =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("signup: 가입 제한 시 403")
|
||||||
|
void signup_disabled() {
|
||||||
|
when(appSettingService.isSignupEnabled()).thenReturn(false);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4"))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("signup: 허니팟(website) 채워지면 400")
|
||||||
|
void signup_honeypot() {
|
||||||
|
when(appSettingService.isSignupEnabled()).thenReturn(true);
|
||||||
|
SignupRequest req = new SignupRequest();
|
||||||
|
req.setWebsite("http://bot");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.signup(req, "1.2.3.4"))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("signup: IP 레이트리밋 초과 시 429")
|
||||||
|
void signup_rateLimited() {
|
||||||
|
when(appSettingService.isSignupEnabled()).thenReturn(true);
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(valueOps.increment(anyString())).thenReturn(6L); // SIGNUP_LIMIT(5) 초과
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4"))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("signup: 정상 가입 시 회원 저장")
|
||||||
|
void signup_ok() {
|
||||||
|
when(appSettingService.isSignupEnabled()).thenReturn(true);
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(valueOps.increment(anyString())).thenReturn(1L);
|
||||||
|
when(memberMapper.countByLoginId("newbie")).thenReturn(0);
|
||||||
|
when(passwordEncoder.encode("password1")).thenReturn("hashed");
|
||||||
|
|
||||||
|
SignupRequest req = new SignupRequest();
|
||||||
|
req.setLoginId("newbie");
|
||||||
|
req.setPassword("password1");
|
||||||
|
req.setName("새회원");
|
||||||
|
req.setEmail("n@b.com");
|
||||||
|
|
||||||
|
MemberResponse res = service.signup(req, "1.2.3.4");
|
||||||
|
|
||||||
|
assertThat(res.getLoginId()).isEqualTo("newbie");
|
||||||
|
verify(memberMapper).insert(any(Member.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== login =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("login: 비밀번호 불일치 시 401")
|
||||||
|
void login_wrongPassword() {
|
||||||
|
when(memberMapper.findByLoginId("u1")).thenReturn(localMember());
|
||||||
|
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
|
||||||
|
|
||||||
|
LoginRequest req = new LoginRequest();
|
||||||
|
req.setLoginId("u1");
|
||||||
|
req.setPassword("bad");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.login(req))
|
||||||
|
.isInstanceOf(ApiException.class)
|
||||||
|
.extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("login: 성공 시 토큰 발급 + Redis/DB 이중 저장")
|
||||||
|
void login_ok() {
|
||||||
|
when(memberMapper.findByLoginId("u1")).thenReturn(localMember());
|
||||||
|
when(passwordEncoder.matches("pw", "hash")).thenReturn(true);
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
|
||||||
|
LoginRequest req = new LoginRequest();
|
||||||
|
req.setLoginId("u1");
|
||||||
|
req.setPassword("pw");
|
||||||
|
req.setRememberMe(true);
|
||||||
|
|
||||||
|
LoginResponse res = service.login(req);
|
||||||
|
|
||||||
|
assertThat(res.getToken()).isNotBlank();
|
||||||
|
verify(valueOps).set(anyString(), any(), any()); // Redis 저장
|
||||||
|
verify(authSessionMapper).insert(any(AuthSession.class)); // DB 백업
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== getSession (DB 백업 복원) =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getSession: Redis 적중 시 그대로 반환")
|
||||||
|
void getSession_redisHit() {
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
SessionUser cached = SessionUser.builder().id(1L).loginId("u1").name("N").role("USER").rememberMe(false).build();
|
||||||
|
when(valueOps.get("session:tok")).thenReturn(cached);
|
||||||
|
|
||||||
|
SessionUser out = service.getSession("tok");
|
||||||
|
|
||||||
|
assertThat(out).isNotNull();
|
||||||
|
assertThat(out.getLoginId()).isEqualTo("u1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getSession: Redis 미스 + DB 유효 → 복원 및 재수화")
|
||||||
|
void getSession_dbFallback() {
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(valueOps.get("session:tok")).thenReturn(null);
|
||||||
|
AuthSession db = AuthSession.builder()
|
||||||
|
.token("tok").memberId(1L).loginId("u1").name("N").role("USER")
|
||||||
|
.provider("LOCAL").rememberMe(true)
|
||||||
|
.expiresAt(LocalDateTime.now().plusDays(1))
|
||||||
|
.build();
|
||||||
|
when(authSessionMapper.findByToken("tok")).thenReturn(db);
|
||||||
|
|
||||||
|
SessionUser out = service.getSession("tok");
|
||||||
|
|
||||||
|
assertThat(out).isNotNull();
|
||||||
|
assertThat(out.getLoginId()).isEqualTo("u1");
|
||||||
|
verify(valueOps).set(eq("session:tok"), any(), any()); // Redis 재수화
|
||||||
|
verify(authSessionMapper).updateExpiry(eq("tok"), any()); // 슬라이딩 갱신
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getSession: Redis 미스 + DB 없음 → null")
|
||||||
|
void getSession_miss() {
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(valueOps.get("session:tok")).thenReturn(null);
|
||||||
|
when(authSessionMapper.findByToken("tok")).thenReturn(null);
|
||||||
|
|
||||||
|
assertThat(service.getSession("tok")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getSession: DB 백업이 만료면 정리하고 null")
|
||||||
|
void getSession_dbExpired() {
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(valueOps.get("session:tok")).thenReturn(null);
|
||||||
|
AuthSession expired = AuthSession.builder()
|
||||||
|
.token("tok").memberId(1L).expiresAt(LocalDateTime.now().minusMinutes(1))
|
||||||
|
.build();
|
||||||
|
when(authSessionMapper.findByToken("tok")).thenReturn(expired);
|
||||||
|
|
||||||
|
assertThat(service.getSession("tok")).isNull();
|
||||||
|
verify(authSessionMapper).delete("tok");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user