Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package org.umc.valuedi.domain.asset.dto.res;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;

import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Schema(description = "자산 통합 응답 DTO")
public class AssetResDTO {
Expand All @@ -30,6 +29,7 @@ public static class AssetSummaryCountDTO {

@Getter
@AllArgsConstructor
@NoArgsConstructor // Map 초기화를 위해 기본 생성자 추가
@Builder
@Schema(description = "AssetFetchService의 내부 처리 결과 DTO (API 응답으로 직접 사용되지 않음)")
public static class AssetSyncResult {
Expand All @@ -50,5 +50,21 @@ public static class AssetSyncResult {

@Schema(description = "가계부 동기화에 사용될 조회 종료일")
private LocalDate toDate;

@Builder.Default
@Schema(description = "계좌 ID별 최신 잔액 맵")
private Map<Long, Long> latestBalances = new HashMap<>();

public boolean hasLatestBalanceFor(Long accountId) {
return latestBalances != null && latestBalances.containsKey(accountId);
}

public Long getLatestBalanceFor(Long accountId) {
return latestBalances.get(accountId);
}

public void addLatestBalance(Long accountId, Long balance) {
this.latestBalances.put(accountId, balance);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,33 +28,30 @@ public class AssetBalanceService {
private final BankAccountRepository bankAccountRepository;
private final BankTransactionRepository bankTransactionRepository;

/**
* 자산 동기화를 수행하고, 특정 계좌의 최신 잔액을 반환합니다.
* 동기화 실패 시 기존 잔액을 반환합니다.
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Long syncAndGetLatestBalance(Long memberId, Long accountId) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The method syncAndGetLatestBalance accepts memberId and accountId as parameters and uses them to fetch and return sensitive financial data (account balance). While it verifies that the accountId belongs to the memberId (line 50), it does not verify that the memberId belongs to the currently authenticated user. An attacker could potentially guess or discover another user's memberId and accountId to retrieve their real-time account balance. It is recommended to verify that the memberId matches the ID of the authenticated user, or retrieve the authenticated user's ID directly from the security context.

Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));

// 자산 동기화 시도
try {
AssetResDTO.AssetSyncResult result = assetFetchService.fetchAndSaveLatestData(member);
log.info("[AssetBalanceService] 자산 동기화 완료. MemberID: {}, NewTransactions: {}, SuccessOrgs: {}, FailedOrgs: {}",
memberId, result.getNewBankTransactionCount(), result.getSuccessOrganizations(), result.getFailureOrganizations());

// 1. 동기화 결과 DTO에 방금 수집한 실시간 잔액이 있다면 DB 조회 없이 즉시 반환 (레이스 컨디션 방지)
if (result.hasLatestBalanceFor(accountId)) {
log.info("[AssetBalanceService] 실시간 동기화 데이터 사용. AccountID: {}, Balance: {}", accountId, result.getLatestBalanceFor(accountId));
return result.getLatestBalanceFor(accountId);
}

} catch (Exception e) {
log.warn("잔액 조회 중 자산 동기화 실패 (기존 잔액 사용): {}", e.getMessage());
log.warn("[AssetBalanceService] 잔액 조회 중 자산 동기화 실패 (기존 DB 잔액 사용): {}", e.getMessage());
}

// 계좌 조회 (영속성 컨텍스트 초기화 가능성 고려하여 재조회 권장)
// 2. Fallback: 실시간 데이터가 없거나 동기화 실패 시 DB에서 최신 데이터 조회
BankAccount account = bankAccountRepository.findByIdAndMemberId(accountId, memberId)
.orElseThrow(() -> new GoalException(GoalErrorCode.ACCOUNT_NOT_FOUND));

// 최신 거래내역 기반 잔액 조회
return bankTransactionRepository.findTopByBankAccountOrderByTrDatetimeDesc(account)
.map(BankTransaction::getAfterBalance)
.orElse(account.getBalanceAmount());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,7 @@

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -59,15 +55,13 @@ public AssetResDTO.AssetSyncResult fetchAndSaveLatestData(Member member) {
.map(CompletableFuture::join)
.toList();

// 모든 거래내역을 한번에 조회하기 위한 준비
List<BankTransaction> allFetchedBankTransactions = new ArrayList<>();
List<CardApproval> allFetchedCardApprovals = new ArrayList<>();

List<String> successOrganizations = new ArrayList<>();
List<String> failureOrganizations = new ArrayList<>();
LocalDate overallMinDate = today;

// 취합된 결과를 바탕으로 DB 저장 및 중복 제거 로직 실행
for (AssetFetchWorker.FetchResult result : fetchResults) {
if (result.isSuccess()) {
successOrganizations.add(result.connection().getOrganization());
Expand All @@ -82,6 +76,8 @@ public AssetResDTO.AssetSyncResult fetchAndSaveLatestData(Member member) {
}
}

Map<Long, Long> realTimeBalances = new HashMap<>();

// 새로운 데이터 필터링 및 저장
int totalNewBankTransactions = 0;
if (!allFetchedBankTransactions.isEmpty()) {
Expand All @@ -90,9 +86,19 @@ public AssetResDTO.AssetSyncResult fetchAndSaveLatestData(Member member) {
bankTransactionRepository.bulkInsert(newBankTransactions);
totalNewBankTransactions = newBankTransactions.size();

// 계좌 잔액 업데이트 로직 추가
// 계좌 잔액 업데이트 (기존 엔티티 반영)
updateAccountBalances(newBankTransactions);
}

// 수집된 모든 거래내역 중 계좌별 가장 최신 잔액을 추출하여 실시간 데이터 맵에 저장
allFetchedBankTransactions.stream()
.collect(Collectors.groupingBy(tx -> tx.getBankAccount().getId(),
Collectors.maxBy(Comparator.comparing(BankTransaction::getTrDatetime))))
.forEach((accountId, optTx) -> optTx.ifPresent(tx -> {
if (tx.getAfterBalance() != null) {
realTimeBalances.put(accountId, tx.getAfterBalance());
}
}));
}

int totalNewCardApprovals = 0;
Expand All @@ -115,20 +121,20 @@ public AssetResDTO.AssetSyncResult fetchAndSaveLatestData(Member member) {
.failureOrganizations(failureOrganizations)
.fromDate(overallMinDate)
.toDate(today)
.latestBalances(realTimeBalances) // 실시간 잔액 데이터 전달
.build();
}

private void updateAccountBalances(List<BankTransaction> transactions) {
// 계좌별로 가장 최신 거래내역을 찾아서 잔액 업데이트
Map<BankAccount, BankTransaction> latestTransactions = transactions.stream()
.collect(Collectors.groupingBy(BankTransaction::getBankAccount,
.collect(Collectors.groupingBy(BankTransaction::getBankAccount,
Collectors.collectingAndThen(
Collectors.maxBy((t1, t2) -> t1.getTrDatetime().compareTo(t2.getTrDatetime())),
java.util.Optional::get
Collectors.maxBy(Comparator.comparing(BankTransaction::getTrDatetime)),
Optional::get
)));

List<BankAccount> updatedAccounts = new ArrayList<>();

latestTransactions.forEach((account, latestTransaction) -> {
if (latestTransaction.getAfterBalance() != null) {
account.updateBalance(latestTransaction.getAfterBalance());
Expand All @@ -145,8 +151,8 @@ private List<BankTransaction> filterNewBankTransactions(List<BankTransaction> al
if (allFetched.isEmpty()) return List.of();

LocalDate minDate = allFetched.stream().map(BankTransaction::getTrDate).min(LocalDate::compareTo).orElse(LocalDate.now());
List<BankAccount> accounts = allFetched.stream().map(BankTransaction::getBankAccount).distinct().collect(Collectors.toList());
List<BankAccount> accounts = allFetched.stream().map(BankTransaction::getBankAccount).distinct().toList();

List<BankTransaction> existingTransactions = bankTransactionRepository.findByBankAccountInAndTrDatetimeAfter(accounts, minDate.atStartOfDay());

Set<BankTransactionKey> existingKeys = existingTransactions.stream()
Expand All @@ -155,14 +161,14 @@ private List<BankTransaction> filterNewBankTransactions(List<BankTransaction> al

return allFetched.stream()
.filter(tx -> !existingKeys.contains(new BankTransactionKey(tx.getTrDatetime(), tx.getInAmount(), tx.getOutAmount(), Objects.toString(tx.getDesc3(), ""))))
.collect(Collectors.toList());
.toList();
}

private List<CardApproval> filterNewCardApprovals(List<CardApproval> allFetched) {
if (allFetched.isEmpty()) return List.of();

List<Card> cards = allFetched.stream().map(CardApproval::getCard).distinct().collect(Collectors.toList());
List<String> approvalNos = allFetched.stream().map(CardApproval::getApprovalNo).distinct().collect(Collectors.toList());
List<Card> cards = allFetched.stream().map(CardApproval::getCard).distinct().toList();
List<String> approvalNos = allFetched.stream().map(CardApproval::getApprovalNo).distinct().toList();

List<CardApproval> existingApprovals = cardApprovalRepository.findByCardInAndApprovalNoIn(cards, approvalNos);

Expand All @@ -172,6 +178,6 @@ private List<CardApproval> filterNewCardApprovals(List<CardApproval> allFetched)

return allFetched.stream()
.filter(ca -> !existingKeys.contains(new CardApprovalKey(ca.getCard(), ca.getApprovalNo())))
.collect(Collectors.toList());
.toList();
}
}
}