diff --git a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java index 66613341..b786ae49 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java +++ b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java @@ -4,12 +4,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.umc.valuedi.domain.asset.exception.code.AssetSuccessCode; import org.umc.valuedi.domain.asset.dto.res.AssetResDTO; import org.umc.valuedi.domain.asset.dto.res.BankResDTO; import org.umc.valuedi.domain.asset.dto.res.CardResDTO; -import org.umc.valuedi.domain.asset.service.AssetQueryService; +import org.umc.valuedi.domain.asset.service.query.AssetQueryService; +import org.umc.valuedi.domain.asset.service.command.AssetSyncFacadeService; import org.umc.valuedi.domain.connection.service.ConnectionQueryService; import org.umc.valuedi.global.apiPayload.ApiResponse; import org.umc.valuedi.global.apiPayload.code.GeneralSuccessCode; @@ -25,6 +28,7 @@ public class AssetController implements AssetControllerDocs { private final ConnectionQueryService connectionQueryService; private final AssetQueryService assetQueryService; + private final AssetSyncFacadeService assetSyncFacadeService; @GetMapping("/cards") @@ -65,8 +69,8 @@ public ApiResponse getAllBankAccounts( return ApiResponse.onSuccess(GeneralSuccessCode.OK, assetQueryService.getAllBankAccounts(memberId)); } - @GetMapping("/banks/{bankCode}/accounts") - public ApiResponse getAccountsByBank( + @GetMapping("/banks/{bankCode}") + public ApiResponse getAccountsByBank( @PathVariable String bankCode, @CurrentMember Long memberId ) { @@ -79,4 +83,12 @@ public ApiResponse getAssetCount( ) { return ApiResponse.onSuccess(GeneralSuccessCode.OK, assetQueryService.getAssetSummaryCount(memberId)); } + + @PostMapping("/sync/refresh") + public ApiResponse refreshAssetSync( + @CurrentMember Long memberId + ) { + assetSyncFacadeService.refreshAssetSync(memberId); + return ApiResponse.onSuccess(AssetSuccessCode.SYNC_REQUEST_SUCCESS, null); + } } diff --git a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java index 4c107a53..6d9382a3 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java +++ b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java @@ -189,13 +189,26 @@ ApiResponse getCardsByIssuer( "result": { "accountList": [ { + "accountId": 1, "accountName": "KB나라사랑우대통장", "balanceAmount": 150000, "organization": "0004", - "createdAt": "2024-05-20T10:00:00" + "createdAt": "2024-05-20T10:00:00", + "goalInfo": null + }, + { + "accountId": 2, + "accountName": "KB국민ONE통장", + "balanceAmount": 300000, + "organization": "0004", + "createdAt": "2024-05-21T10:00:00", + "goalInfo": { + "goalId": 2, + "title": "여행" + } } ], - "totalCount": 1 + "totalCount": 2 } } """ @@ -205,38 +218,77 @@ ApiResponse getCardsByIssuer( }) ApiResponse getAllBankAccounts(@CurrentMember Long memberId); - @Operation(summary = "은행별 계좌 목록 조회", description = "특정 은행에 연동된 계좌 목록을 조회합니다.") + @Operation(summary = "은행별 계좌 및 목표 목록 조회", description = "특정 은행에 연동된 계좌와 목표 목록을 조회합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "성공 - 은행별 계좌 목록 반환", content = @Content( schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject( - name = "성공 예시", - value = """ - { - "isSuccess": true, - "code": "COMMON200", - "message": "성공입니다.", - "result": { - "accountList": [ - { - "accountName": "KB나라사랑우대통장", - "balanceAmount": 150000, - "organization": "0004", - "createdAt": "2024-05-20T10:00:00" - } - ], - "totalCount": 1 - } - } - """ - ) + examples = { + @ExampleObject( + name = "목표가 있는 경우", + value = """ + { + "isSuccess": true, + "code": "COMMON200", + "message": "요청이 성공적으로 처리되었습니다.", + "result": { + "bankName": "우리은행", + "totalBalance": 240732, + "accountList": [ + { + "accountId": 2, + "accountName": "저축예금", + "balanceAmount": 220732, + "connectedGoalId": null + }, + { + "accountId": 3, + "accountName": "청약저축", + "balanceAmount": 20000, + "connectedGoalId": 101 + } + ], + "goalList": [ + { + "goalId": 2, + "title": "여행행", + "linkedAccountId": 2 + } + ] + } + } + """ + ), + @ExampleObject( + name = "목표가 없는 경우", + value = """ + { + "isSuccess": true, + "code": "COMMON200", + "message": "요청이 성공적으로 처리되었습니다.", + "result": { + "bankName": "우리은행", + "totalBalance": 100000, + "accountList": [ + { + "accountId": 2, + "accountName": "저축예금", + "balanceAmount": 100000, + "connectedGoalId": null + } + ], + "goalList": [] + } + } + """ + ) + } ) ) }) - ApiResponse getAccountsByBank( + ApiResponse getAccountsByBank( @Parameter(description = "은행 코드 (예: 0020)") String organization, @CurrentMember Long memberId ); @@ -267,4 +319,52 @@ ApiResponse getAccountsByBank( ) }) ApiResponse getAssetCount(@CurrentMember Long memberId); + + @Operation( + summary = "전체 자산 새로고침(동기화) 요청 API", + description = """ + 사용자가 연동한 모든 금융사의 최신 거래내역 수집을 **백그라운드에서 시작**하도록 요청합니다. + - **즉시 응답**: API는 동기화 작업을 백그라운드로 넘기고 즉시 '요청 성공' 응답을 반환합니다. + - **결과 확인**: 실제 동기화 결과는 잠시 후 자산 관련 다른 API를 통해 확인해야 합니다. + - **10분 쿨타임**: 마지막 동기화 시간으로부터 10분 이내에는 재호출할 수 없습니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공 - 동기화 작업이 성공적으로 시작됨", + content = @Content( + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "isSuccess": true, + "code": "ASSET200_1", + "message": "자산 동기화 요청이 성공적으로 접수되었습니다. 잠시 후 데이터를 확인해주세요.", + "result": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "429", + description = "실패 - 10분 쿨타임 제한", + content = @Content( + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "쿨타임 실패 예시", + value = """ + { + "isSuccess": false, + "code": "ASSET429_1", + "message": "자산 동기화는 10분에 한 번만 요청할 수 있습니다." + } + """ + ) + ) + ) + }) + ApiResponse refreshAssetSync(@Parameter(hidden = true) @CurrentMember Long memberId); } diff --git a/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java b/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java index 208db8f7..6c272265 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java +++ b/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java @@ -1,25 +1,38 @@ package org.umc.valuedi.domain.asset.converter; -import org.springframework.stereotype.Component; import org.umc.valuedi.domain.asset.dto.res.AssetResDTO; import org.umc.valuedi.domain.asset.dto.res.BankResDTO; import org.umc.valuedi.domain.asset.dto.res.CardResDTO; import org.umc.valuedi.domain.asset.entity.BankAccount; import org.umc.valuedi.domain.asset.entity.Card; +import org.umc.valuedi.domain.connection.enums.Organization; +import org.umc.valuedi.domain.goal.entity.Goal; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; -@Component public class AssetConverter { // 개별 BankAccount 엔티티 -> BankAccountInfo 변환 public static BankResDTO.BankAccountInfo toBankAccountInfo(BankAccount account) { + BankResDTO.GoalInfo goalInfo = null; + if (account.getGoal() != null) { + Goal goal = account.getGoal(); + + goalInfo = BankResDTO.GoalInfo.builder() + .goalId(goal.getId()) + .title(goal.getTitle()) + .build(); + } + return BankResDTO.BankAccountInfo.builder() + .accountId(account.getId()) .accountName(account.getAccountName()) .balanceAmount(account.getBalanceAmount()) .organization(account.getCodefConnection().getOrganization()) // 기관코드 추출 .createdAt(account.getCreatedAt()) + .goalInfo(goalInfo) .build(); } @@ -66,4 +79,42 @@ public static AssetResDTO.AssetSummaryCountDTO toAssetSummaryCountDTO(long accou .totalAssetCount(accountCount + cardCount) .build(); } + + // 특정 은행의 자산 현황 응답 DTO 변환 + public static BankResDTO.BankAssetResponse toBankAssetResponse(String organizationCode, List accounts) { + String bankName = Organization.getNameByCode(organizationCode); + + long totalBalance = 0L; + List accountList = new ArrayList<>(accounts.size()); + List goalList = new ArrayList<>(); + + for (BankAccount account : accounts) { + totalBalance += (account.getBalanceAmount() != null ? account.getBalanceAmount() : 0L); + + Goal goal = account.getGoal(); + Long connectedGoalId = (goal != null) ? goal.getId() : null; + + accountList.add(BankResDTO.AccountInfo.builder() + .accountId(account.getId()) + .accountName(account.getAccountName()) + .balanceAmount(account.getBalanceAmount()) + .connectedGoalId(connectedGoalId) + .build()); + + if (goal != null) { + goalList.add(BankResDTO.GoalSimpleInfo.builder() + .goalId(goal.getId()) + .title(goal.getTitle()) + .linkedAccountId(account.getId()) + .build()); + } + } + + return BankResDTO.BankAssetResponse.builder() + .bankName(bankName) + .totalBalance(totalBalance) + .accountList(accountList) + .goalList(goalList) + .build(); + } } diff --git a/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java b/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java index a1cdc956..ace3564c 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java +++ b/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java @@ -6,6 +6,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDate; +import java.util.List; + @Schema(description = "자산 통합 응답 DTO") public class AssetResDTO { @@ -24,4 +27,28 @@ public static class AssetSummaryCountDTO { @Schema(description = "총 연동 자산 수 (계좌 + 카드)", example = "8") private Long totalAssetCount; } -} \ No newline at end of file + + @Getter + @AllArgsConstructor + @Builder + @Schema(description = "AssetFetchService의 내부 처리 결과 DTO (API 응답으로 직접 사용되지 않음)") + public static class AssetSyncResult { + @Schema(description = "새로 수집된 은행 거래내역 수") + private int newBankTransactionCount; + + @Schema(description = "새로 수집된 카드 승인내역 수") + private int newCardApprovalCount; + + @Schema(description = "데이터 수집에 성공한 기관 목록") + private List successOrganizations; + + @Schema(description = "데이터 수집에 실패한 기관 목록") + private List failureOrganizations; + + @Schema(description = "가계부 동기화에 사용될 조회 시작일") + private LocalDate fromDate; + + @Schema(description = "가계부 동기화에 사용될 조회 종료일") + private LocalDate toDate; + } +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/dto/res/BankResDTO.java b/src/main/java/org/umc/valuedi/domain/asset/dto/res/BankResDTO.java index 69a9020f..25f79d24 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/dto/res/BankResDTO.java +++ b/src/main/java/org/umc/valuedi/domain/asset/dto/res/BankResDTO.java @@ -35,6 +35,19 @@ public static class BankConnection { private ConnectionStatus status; } + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "목표 정보") + public static class GoalInfo { + @Schema(description = "목표 ID", example = "1") + private Long goalId; + + @Schema(description = "목표명", example = "푸꾸옥여행가고싶어요") + private String title; + } + @Getter @Builder @NoArgsConstructor @@ -42,6 +55,9 @@ public static class BankConnection { @Schema(description = "개별 계좌 정보") public static class BankAccountInfo { + @Schema(description = "계좌 ID", example = "1") + private Long accountId; + @Schema(description = "계좌명", example = "KB국민ONE통장") private String accountName; @@ -53,6 +69,9 @@ public static class BankAccountInfo { @Schema(description = "계좌 등록일시 (정렬 기준 확인용)", example = "2026-01-19T10:00:00") private LocalDateTime createdAt; + + @Schema(description = "연결된 목표 정보 (없으면 null)") + private GoalInfo goalInfo; } @Getter @@ -68,4 +87,59 @@ public static class BankAccountListDTO { @Schema(description = "총 계좌 개수", example = "1") private Integer totalCount; } + + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "은행별 자산 조회 응답") + public static class BankAssetResponse { + @Schema(description = "은행명", example = "우리은행") + private String bankName; + + @Schema(description = "총 잔액", example = "240732") + private Long totalBalance; + + @Schema(description = "계좌 목록") + private List accountList; + + @Schema(description = "목표 목록") + private List goalList; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "간략한 계좌 정보") + public static class AccountInfo { + @Schema(description = "계좌 ID", example = "1") + private Long accountId; + + @Schema(description = "계좌명", example = "저축예금") + private String accountName; + + @Schema(description = "잔액", example = "220732") + private Long balanceAmount; + + @Schema(description = "연결된 목표 ID (없으면 null)", example = "1") + private Long connectedGoalId; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "간략한 목표 정보") + public static class GoalSimpleInfo { + @Schema(description = "목표 ID", example = "1") + private Long goalId; + + @Schema(description = "목표명", example = "여행") + private String title; + + @Schema(description = "연결된 계좌 ID", example = "1") + private Long linkedAccountId; + } } diff --git a/src/main/java/org/umc/valuedi/domain/asset/entity/BankAccount.java b/src/main/java/org/umc/valuedi/domain/asset/entity/BankAccount.java index 752c301d..0c6cbdc0 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/entity/BankAccount.java +++ b/src/main/java/org/umc/valuedi/domain/asset/entity/BankAccount.java @@ -5,6 +5,7 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; import org.umc.valuedi.domain.asset.enums.AccountGroup; +import org.umc.valuedi.domain.goal.entity.Goal; import org.umc.valuedi.global.entity.BaseEntity; import org.umc.valuedi.domain.connection.entity.CodefConnection; @@ -85,6 +86,9 @@ public class BankAccount extends BaseEntity { @JoinColumn(name = "codef_connection_id", nullable = false) private CodefConnection codefConnection; + @OneToOne(mappedBy = "bankAccount", fetch = FetchType.LAZY) + private Goal goal; + public void assignConnection(CodefConnection connection) { this.codefConnection = connection; } diff --git a/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java b/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java index 328c794e..8e8181e7 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java +++ b/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java @@ -17,7 +17,15 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter -@Table(name = "bank_transaction") +@Table( + name = "bank_transaction", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_bank_transaction_identity", + columnNames = {"bank_account_id", "tr_datetime", "in_amount", "out_amount", "desc3"} + ) + } +) public class BankTransaction extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/org/umc/valuedi/domain/asset/exception/AssetException.java b/src/main/java/org/umc/valuedi/domain/asset/exception/AssetException.java new file mode 100644 index 00000000..7051857e --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/exception/AssetException.java @@ -0,0 +1,10 @@ +package org.umc.valuedi.domain.asset.exception; + +import org.umc.valuedi.global.apiPayload.code.BaseErrorCode; +import org.umc.valuedi.global.apiPayload.exception.GeneralException; + +public class AssetException extends GeneralException { + public AssetException(BaseErrorCode code) { + super(code); + } +} \ No newline at end of file diff --git a/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java new file mode 100644 index 00000000..4bae5fa9 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java @@ -0,0 +1,18 @@ +package org.umc.valuedi.domain.asset.exception.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.umc.valuedi.global.apiPayload.code.BaseErrorCode; + +@Getter +@AllArgsConstructor +public enum AssetErrorCode implements BaseErrorCode { + + SYNC_COOL_DOWN(HttpStatus.TOO_MANY_REQUESTS, "ASSET429_1", "전체 동기화는 10분에 한 번만 요청할 수 있습니다."), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java new file mode 100644 index 00000000..6b466093 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java @@ -0,0 +1,19 @@ +package org.umc.valuedi.domain.asset.exception.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.umc.valuedi.global.apiPayload.code.BaseSuccessCode; + + +@Getter +@AllArgsConstructor +public enum AssetSuccessCode implements BaseSuccessCode { + + SYNC_REQUEST_SUCCESS(HttpStatus.OK, "ASSET200_1", "자산 동기화 요청이 성공적으로 접수되었습니다. 잠시 후 데이터를 확인해주세요."), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankAccountRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankAccountRepository.java deleted file mode 100644 index e605f902..00000000 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankAccountRepository.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.umc.valuedi.domain.asset.repository.bank; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.umc.valuedi.domain.asset.entity.BankAccount; - -import java.util.List; - -public interface BankAccountRepository extends JpaRepository { - - /** - * 전체 활성 계좌 목록 조회 (최신순) - */ - @Query("SELECT ba FROM BankAccount ba " + - "JOIN FETCH ba.codefConnection cc " + - "WHERE cc.member.id = :memberId " + - "AND ba.isActive = true " + - "ORDER BY ba.createdAt DESC") - List findAllByMemberId(@Param("memberId") Long memberId); - - /** - * 특정 은행별 활성 계좌 목록 조회 - */ - @Query("SELECT ba FROM BankAccount ba " + - "JOIN FETCH ba.codefConnection cc " + - "WHERE cc.member.id = :memberId " + - "AND cc.organization = :organization " + - "AND ba.isActive = true") - List findAllByMemberIdAndOrganization( - @Param("memberId") Long memberId, - @Param("organization") String organization - ); - - /** - * 총 활성 계좌 수 카운트 - */ - @Query("SELECT COUNT(ba) FROM BankAccount ba " + - "WHERE ba.codefConnection.member.id = :memberId " + - "AND ba.isActive = true") - long countByMemberId(@Param("memberId") Long memberId); -} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepository.java deleted file mode 100644 index 7ac97e80..00000000 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.umc.valuedi.domain.asset.repository.bank; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.umc.valuedi.domain.asset.entity.BankTransaction; - -import java.time.LocalDate; -import java.util.List; - -public interface BankTransactionRepository extends JpaRepository, BankTransactionRepositoryCustom { - List findByTrDateBetween(LocalDate fromDate, LocalDate toDate); -} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepository.java new file mode 100644 index 00000000..d817e779 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepository.java @@ -0,0 +1,7 @@ +package org.umc.valuedi.domain.asset.repository.bank.bankAccount; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.umc.valuedi.domain.asset.entity.BankAccount; + +public interface BankAccountRepository extends JpaRepository, BankAccountRepositoryCustom { +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryCustom.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryCustom.java new file mode 100644 index 00000000..d5a699be --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryCustom.java @@ -0,0 +1,23 @@ +package org.umc.valuedi.domain.asset.repository.bank.bankAccount; + +import org.umc.valuedi.domain.asset.entity.BankAccount; + +import java.util.List; +import java.util.Optional; + +public interface BankAccountRepositoryCustom { + // 특정 은행별 활성 계좌 목록 조회 + List findAllByMemberIdAndOrganization(Long memberId, String organization); + + // 전체 활성 계좌 목록 조회 (최신순) + List findAllByMemberId(Long memberId); + + // 총 활성 계좌 수 카운트 + long countByMemberId(Long memberId); + + //목표와 연결되지 않은 계좌 목록 조회 + List findUnlinkedByMemberId(Long memberId, List excludeIds); + + //특정 계좌(accountId)가 해당 회원(memberId)의 소유이며 활성 상태인지 조회 + Optional findByIdAndMemberId(Long accountId, Long memberId); +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryImpl.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryImpl.java new file mode 100644 index 00000000..9e57b2e8 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryImpl.java @@ -0,0 +1,98 @@ +package org.umc.valuedi.domain.asset.repository.bank.bankAccount; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.umc.valuedi.domain.asset.entity.BankAccount; + +import java.util.List; +import java.util.Optional; + +import static org.umc.valuedi.domain.asset.entity.QBankAccount.bankAccount; +import static org.umc.valuedi.domain.connection.entity.QCodefConnection.codefConnection; +import static org.umc.valuedi.domain.goal.entity.QGoal.goal; + +@RequiredArgsConstructor +public class BankAccountRepositoryImpl implements BankAccountRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByMemberIdAndOrganization(Long memberId, String organization) { + return queryFactory + .selectFrom(bankAccount) + .join(bankAccount.codefConnection, codefConnection).fetchJoin() + .leftJoin(bankAccount.goal, goal).fetchJoin() + .where( + codefConnection.member.id.eq(memberId), + codefConnection.organization.eq(organization), + bankAccount.isActive.isTrue() + ) + .fetch(); + } + + @Override + public List findAllByMemberId(Long memberId) { + return queryFactory + .selectFrom(bankAccount) + .join(bankAccount.codefConnection, codefConnection).fetchJoin() + .leftJoin(bankAccount.goal, goal).fetchJoin() + .where( + codefConnection.member.id.eq(memberId), + bankAccount.isActive.isTrue() + ) + .orderBy(bankAccount.createdAt.desc()) + .fetch(); + } + + @Override + public long countByMemberId(Long memberId) { + Long count = queryFactory + .select(bankAccount.count()) + .from(bankAccount) + .join(bankAccount.codefConnection, codefConnection) + .where( + codefConnection.member.id.eq(memberId), + bankAccount.isActive.isTrue() + ) + .fetchOne(); + return count != null ? count : 0; + } + + @Override + public List findUnlinkedByMemberId(Long memberId, List excludeIds) { + boolean hasExcludeIds = (excludeIds != null && !excludeIds.isEmpty()); + + var base = queryFactory + .selectFrom(bankAccount) + .join(bankAccount.codefConnection, codefConnection).fetchJoin() + .leftJoin(bankAccount.goal, goal).fetchJoin() + .where( + codefConnection.member.id.eq(memberId), + bankAccount.isActive.isTrue() + ); + + if (hasExcludeIds) { + base.where(bankAccount.id.notIn(excludeIds)); + } + + return base + .orderBy(bankAccount.id.desc()) + .fetch(); + } + + @Override + public Optional findByIdAndMemberId(Long accountId, Long memberId) { + BankAccount result = queryFactory + .selectFrom(bankAccount) + .join(bankAccount.codefConnection, codefConnection).fetchJoin() + .leftJoin(bankAccount.goal, goal).fetchJoin() + .where( + bankAccount.id.eq(accountId), + codefConnection.member.id.eq(memberId), + bankAccount.isActive.isTrue() + ) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepository.java new file mode 100644 index 00000000..2a4e28ef --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepository.java @@ -0,0 +1,21 @@ +package org.umc.valuedi.domain.asset.repository.bank.bankTransaction; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.entity.BankTransaction; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface BankTransactionRepository extends JpaRepository, BankTransactionRepositoryCustom { + List findByTrDateBetween(LocalDate fromDate, LocalDate toDate); + + @Query("SELECT MAX(bt.trDate) FROM BankTransaction bt WHERE bt.bankAccount = :account") + Optional findLatestTransactionDateByAccount(@Param("account") BankAccount account); + + List findByBankAccountInAndTrDatetimeAfter(List accounts, LocalDateTime startTime); +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepositoryCustom.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryCustom.java similarity index 73% rename from src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepositoryCustom.java rename to src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryCustom.java index f4048d43..f48a55b1 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepositoryCustom.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryCustom.java @@ -1,4 +1,4 @@ -package org.umc.valuedi.domain.asset.repository.bank; +package org.umc.valuedi.domain.asset.repository.bank.bankTransaction; import org.umc.valuedi.domain.asset.entity.BankTransaction; import java.util.List; diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepositoryImpl.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryImpl.java similarity index 92% rename from src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepositoryImpl.java rename to src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryImpl.java index fc3ae7c8..f183c577 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepositoryImpl.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryImpl.java @@ -1,8 +1,9 @@ -package org.umc.valuedi.domain.asset.repository.bank; +package org.umc.valuedi.domain.asset.repository.bank.bankTransaction; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; import org.umc.valuedi.domain.asset.entity.BankTransaction; import java.sql.PreparedStatement; @@ -10,6 +11,7 @@ import java.sql.Timestamp; import java.util.List; +@Repository @RequiredArgsConstructor public class BankTransactionRepositoryImpl implements BankTransactionRepositoryCustom { @@ -17,7 +19,7 @@ public class BankTransactionRepositoryImpl implements BankTransactionRepositoryC @Override public void bulkInsert(List transactions) { - if (transactions.isEmpty()) { + if (transactions == null || transactions.isEmpty()) { return; } diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepository.java deleted file mode 100644 index 6fe497b4..00000000 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.umc.valuedi.domain.asset.repository.card; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.umc.valuedi.domain.asset.entity.CardApproval; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -public interface CardApprovalRepository extends JpaRepository, CardApprovalRepositoryCustom { - List findByUsedDateBetween(LocalDate fromDate, LocalDate toDate); - boolean existsByUsedAmountAndUsedDatetimeBetween(Long amount, LocalDateTime start, LocalDateTime end); -} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/card/card/CardRepository.java similarity index 91% rename from src/main/java/org/umc/valuedi/domain/asset/repository/card/CardRepository.java rename to src/main/java/org/umc/valuedi/domain/asset/repository/card/card/CardRepository.java index 0a531ca3..06a643a8 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardRepository.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/card/card/CardRepository.java @@ -1,4 +1,4 @@ -package org.umc.valuedi.domain.asset.repository.card; +package org.umc.valuedi.domain.asset.repository.card.card; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -32,4 +32,6 @@ public interface CardRepository extends JpaRepository { "AND c.isActive = true " + "ORDER BY c.createdAt DESC") List findAllByMemberId(@Param("memberId") Long memberId); + + List findAllByCodefConnection(CodefConnection connection); } diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepository.java new file mode 100644 index 00000000..2a52d005 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepository.java @@ -0,0 +1,24 @@ +package org.umc.valuedi.domain.asset.repository.card.cardApproval; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.umc.valuedi.domain.asset.entity.Card; +import org.umc.valuedi.domain.asset.entity.CardApproval; +import org.umc.valuedi.domain.member.entity.Member; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface CardApprovalRepository extends JpaRepository, CardApprovalRepositoryCustom { + List findByUsedDateBetween(LocalDate fromDate, LocalDate toDate); + boolean existsByUsedAmountAndUsedDatetimeBetween(Long amount, LocalDateTime start, LocalDateTime end); + + @Query("SELECT MAX(ca.usedDate) FROM CardApproval ca JOIN ca.card c WHERE c.codefConnection.member = :member") + Optional findLatestApprovalDateByMember(@Param("member") Member member); + + List findByCardInAndApprovalNoIn(List cards, List approvalNos); +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepositoryCustom.java b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryCustom.java similarity index 73% rename from src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepositoryCustom.java rename to src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryCustom.java index b8f52ada..aa60d96f 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepositoryCustom.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryCustom.java @@ -1,4 +1,4 @@ -package org.umc.valuedi.domain.asset.repository.card; +package org.umc.valuedi.domain.asset.repository.card.cardApproval; import org.umc.valuedi.domain.asset.entity.CardApproval; import java.util.List; diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepositoryImpl.java b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryImpl.java similarity index 95% rename from src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepositoryImpl.java rename to src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryImpl.java index 8b30932a..64877888 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepositoryImpl.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryImpl.java @@ -1,8 +1,9 @@ -package org.umc.valuedi.domain.asset.repository.card; +package org.umc.valuedi.domain.asset.repository.card.cardApproval; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; import org.umc.valuedi.domain.asset.entity.CardApproval; import java.sql.PreparedStatement; @@ -10,6 +11,7 @@ import java.sql.Timestamp; import java.util.List; +@Repository @RequiredArgsConstructor public class CardApprovalRepositoryImpl implements CardApprovalRepositoryCustom { @@ -17,7 +19,7 @@ public class CardApprovalRepositoryImpl implements CardApprovalRepositoryCustom @Override public void bulkInsert(List approvals) { - if (approvals.isEmpty()) { + if (approvals == null || approvals.isEmpty()) { return; } diff --git a/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetFetchService.java b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetFetchService.java new file mode 100644 index 00000000..692491a0 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetFetchService.java @@ -0,0 +1,147 @@ +package org.umc.valuedi.domain.asset.service.command; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.umc.valuedi.domain.asset.dto.res.AssetResDTO; +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.entity.BankTransaction; +import org.umc.valuedi.domain.asset.entity.Card; +import org.umc.valuedi.domain.asset.entity.CardApproval; +import org.umc.valuedi.domain.asset.repository.bank.bankTransaction.BankTransactionRepository; +import org.umc.valuedi.domain.asset.repository.card.cardApproval.CardApprovalRepository; +import org.umc.valuedi.domain.asset.service.command.worker.AssetFetchWorker; +import org.umc.valuedi.domain.connection.entity.CodefConnection; +import org.umc.valuedi.domain.connection.repository.CodefConnectionRepository; +import org.umc.valuedi.domain.member.entity.Member; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AssetFetchService { + + private final EntityManager entityManager; + private final CodefConnectionRepository codefConnectionRepository; + private final BankTransactionRepository bankTransactionRepository; + private final CardApprovalRepository cardApprovalRepository; + private final AssetFetchWorker assetFetchWorker; + + private record BankTransactionKey(LocalDateTime trDatetime, Long inAmount, Long outAmount, String desc3) {} + private record CardApprovalKey(Card card, String approvalNo) {} + + @Transactional + public AssetResDTO.AssetSyncResult fetchAndSaveLatestData(Member member) { + List connections = codefConnectionRepository.findByMemberId(member.getId()); + LocalDate today = LocalDate.now(); + + // 각 기관별로 비동기 API 호출 실행 + List> futures = connections.stream() + .map(connection -> assetFetchWorker.fetchAndConvertData(connection, member)) + .collect(Collectors.toList()); + + // 모든 비동기 작업이 완료될 때까지 대기하고 결과 취합 + List fetchResults = futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList()); + + // 모든 거래내역을 한번에 조회하기 위한 준비 + List allFetchedBankTransactions = new ArrayList<>(); + List allFetchedCardApprovals = new ArrayList<>(); + + List successOrganizations = new ArrayList<>(); + List failureOrganizations = new ArrayList<>(); + LocalDate overallMinDate = today; + + // 취합된 결과를 바탕으로 DB 저장 및 중복 제거 로직 실행 + for (AssetFetchWorker.FetchResult result : fetchResults) { + if (result.isSuccess()) { + successOrganizations.add(result.connection().getOrganization()); + allFetchedBankTransactions.addAll(result.bankTransactions()); + allFetchedCardApprovals.addAll(result.cardApprovals()); + if (result.startDate().isBefore(overallMinDate)) { + overallMinDate = result.startDate(); + } + } else { + failureOrganizations.add(result.connection().getOrganization()); + log.error("[ASSET-FETCH] 자산 데이터 수집 비동기 작업 실패. 기관: {}, 회원 ID: {}", result.connection().getOrganization(), member.getId()); + } + } + + // 새로운 데이터 필터링 및 저장 + int totalNewBankTransactions = 0; + if (!allFetchedBankTransactions.isEmpty()) { + List newBankTransactions = filterNewBankTransactions(allFetchedBankTransactions); + if (!newBankTransactions.isEmpty()) { + bankTransactionRepository.bulkInsert(newBankTransactions); + totalNewBankTransactions = newBankTransactions.size(); + } + } + + int totalNewCardApprovals = 0; + if (!allFetchedCardApprovals.isEmpty()) { + List newCardApprovals = filterNewCardApprovals(allFetchedCardApprovals); + if (!newCardApprovals.isEmpty()) { + cardApprovalRepository.bulkInsert(newCardApprovals); + totalNewCardApprovals = newCardApprovals.size(); + } + } + + // JdbcTemplate 사용 후 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + return AssetResDTO.AssetSyncResult.builder() + .newBankTransactionCount(totalNewBankTransactions) + .newCardApprovalCount(totalNewCardApprovals) + .successOrganizations(successOrganizations) + .failureOrganizations(failureOrganizations) + .fromDate(overallMinDate) + .toDate(today) + .build(); + } + + private List filterNewBankTransactions(List allFetched) { + if (allFetched.isEmpty()) return List.of(); + + LocalDate minDate = allFetched.stream().map(BankTransaction::getTrDate).min(LocalDate::compareTo).orElse(LocalDate.now()); + List accounts = allFetched.stream().map(BankTransaction::getBankAccount).distinct().collect(Collectors.toList()); + + List existingTransactions = bankTransactionRepository.findByBankAccountInAndTrDatetimeAfter(accounts, minDate.atStartOfDay()); + + Set existingKeys = existingTransactions.stream() + .map(tx -> new BankTransactionKey(tx.getTrDatetime(), tx.getInAmount(), tx.getOutAmount(), Objects.toString(tx.getDesc3(), ""))) + .collect(Collectors.toSet()); + + return allFetched.stream() + .filter(tx -> !existingKeys.contains(new BankTransactionKey(tx.getTrDatetime(), tx.getInAmount(), tx.getOutAmount(), Objects.toString(tx.getDesc3(), "")))) + .collect(Collectors.toList()); + } + + private List filterNewCardApprovals(List allFetched) { + if (allFetched.isEmpty()) return List.of(); + + List cards = allFetched.stream().map(CardApproval::getCard).distinct().collect(Collectors.toList()); + List approvalNos = allFetched.stream().map(CardApproval::getApprovalNo).distinct().collect(Collectors.toList()); + + List existingApprovals = cardApprovalRepository.findByCardInAndApprovalNoIn(cards, approvalNos); + + Set existingKeys = existingApprovals.stream() + .map(ca -> new CardApprovalKey(ca.getCard(), ca.getApprovalNo())) + .collect(Collectors.toSet()); + + return allFetched.stream() + .filter(ca -> !existingKeys.contains(new CardApprovalKey(ca.getCard(), ca.getApprovalNo()))) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncFacadeService.java b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncFacadeService.java new file mode 100644 index 00000000..8cbb15e6 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncFacadeService.java @@ -0,0 +1,49 @@ +package org.umc.valuedi.domain.asset.service.command; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.umc.valuedi.domain.asset.exception.AssetException; +import org.umc.valuedi.domain.asset.exception.code.AssetErrorCode; +import org.umc.valuedi.domain.member.entity.Member; +import org.umc.valuedi.domain.member.exception.MemberException; +import org.umc.valuedi.domain.member.exception.code.MemberErrorCode; +import org.umc.valuedi.domain.member.repository.MemberRepository; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class AssetSyncFacadeService { + + private final MemberRepository memberRepository; + private final AssetSyncProcessor assetSyncProcessor; // 실제 비동기 작업을 수행할 서비스 주입 + + private static final long SYNC_COOL_DOWN_MINUTES = 10; + + /** + * 동기화 요청을 접수하고, 실제 작업은 백그라운드로 넘깁니다. + */ + public void refreshAssetSync(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + validateSyncCoolDown(member); + + // 실제 동기화 프로세스를 비동기적으로 호출 + assetSyncProcessor.runSyncProcess(member); + } + + private void validateSyncCoolDown(Member member) { + if (member.getLastSyncedAt() == null) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + Duration duration = Duration.between(member.getLastSyncedAt(), now); + + if (duration.toMinutes() < SYNC_COOL_DOWN_MINUTES) { + throw new AssetException(AssetErrorCode.SYNC_COOL_DOWN); + } + } +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncProcessor.java b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncProcessor.java new file mode 100644 index 00000000..cc50e8bc --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncProcessor.java @@ -0,0 +1,50 @@ +package org.umc.valuedi.domain.asset.service.command; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.umc.valuedi.domain.asset.dto.res.AssetResDTO; +import org.umc.valuedi.domain.ledger.service.command.LedgerSyncService; +import org.umc.valuedi.domain.member.entity.Member; + +import java.time.LocalDate; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AssetSyncProcessor { + + private final AssetFetchService assetFetchService; + private final LedgerSyncService ledgerSyncService; + + /** + * 실제 동기화 로직을 수행하는 비동기 메서드 + */ + @Async("assetFetchExecutor") + public void runSyncProcess(Member member) { + log.info("자산 동기화 백그라운드 작업을 시작합니다. 회원 ID: {}", member.getId()); + try { + // 트랜잭션 1: 데이터 수집 및 저장 + AssetResDTO.AssetSyncResult syncResult = assetFetchService.fetchAndSaveLatestData(member); + + // 새로 수집된 데이터가 있을 경우에만 가계부 동기화 로직 수행 + boolean hasNewData = syncResult.getNewBankTransactionCount() > 0 || syncResult.getNewCardApprovalCount() > 0; + if (hasNewData) { + LocalDate fromDate = syncResult.getFromDate(); + LocalDate toDate = syncResult.getToDate(); + + // 트랜잭션 2: 가계부 연동 및 최종 업데이트 + // Member 객체 대신 ID를 전달하여 영속성 컨텍스트 내에서 조회 및 업데이트하도록 변경 + ledgerSyncService.syncTransactionsAndUpdateMember(member.getId(), fromDate, toDate); + } else { + // 새로 수집된 데이터가 없어도 동기화 시간은 갱신 + ledgerSyncService.updateMemberLastSyncedAt(member.getId()); + } + log.info("자산 동기화 백그라운드 작업을 성공적으로 완료했습니다. 회원 ID: {}", member.getId()); + } catch (Exception e) { + // 비동기 작업 내에서 발생하는 모든 예외를 로깅 + log.error("자산 동기화 백그라운드 작업 중 오류 발생. 회원 ID: {}", member.getId(), e); + } + } +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/service/AssetSyncService.java b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncService.java similarity index 67% rename from src/main/java/org/umc/valuedi/domain/asset/service/AssetSyncService.java rename to src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncService.java index 002f2f97..0d58ea3b 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/service/AssetSyncService.java +++ b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncService.java @@ -1,7 +1,5 @@ -package org.umc.valuedi.domain.asset.service; +package org.umc.valuedi.domain.asset.service.command; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,15 +10,14 @@ import org.umc.valuedi.domain.asset.entity.BankTransaction; import org.umc.valuedi.domain.asset.entity.Card; import org.umc.valuedi.domain.asset.entity.CardApproval; -import org.umc.valuedi.domain.asset.repository.bank.BankAccountRepository; -import org.umc.valuedi.domain.asset.repository.bank.BankTransactionRepository; -import org.umc.valuedi.domain.asset.repository.card.CardApprovalRepository; -import org.umc.valuedi.domain.asset.repository.card.CardRepository; +import org.umc.valuedi.domain.asset.repository.bank.bankAccount.BankAccountRepository; +import org.umc.valuedi.domain.asset.repository.bank.bankTransaction.BankTransactionRepository; +import org.umc.valuedi.domain.asset.repository.card.cardApproval.CardApprovalRepository; +import org.umc.valuedi.domain.asset.repository.card.card.CardRepository; import org.umc.valuedi.domain.connection.entity.CodefConnection; import org.umc.valuedi.domain.connection.enums.BusinessType; import org.umc.valuedi.global.external.codef.service.CodefAssetService; -import java.util.ArrayList; import java.util.List; @Slf4j @@ -134,71 +131,24 @@ private void syncCardApprovals(CodefConnection connection) { log.warn("연동된 카드가 없어 승인내역 동기화를 건너뜁니다."); return; } + try { + + connection.getCardList().clear(); + connection.getCardList().addAll(cards); + } catch (Exception e) { + log.warn("Connection 객체의 카드 리스트 갱신 중 오류 (무시하고 진행): {}", e.getMessage()); + } // 전체 승인 내역 조회 (API) + // CodefAssetService 내부에서 CodefAssetConverter를 통해 매칭까지 완료된 리스트 반환 List approvals = codefAssetService.getCardApprovals(connection); if (approvals.isEmpty()) { log.info("조회된 승인내역이 없습니다."); return; } - List matchedApprovals = new ArrayList<>(); - - // 매칭 로직 - for (CardApproval approval : approvals) { - String resCardNo = extractResCardNo(approval); - if (resCardNo == null) { - log.warn("승인내역에서 카드번호를 찾을 수 없어 스킵합니다. ApprovalNo: {}", approval.getApprovalNo()); - continue; - } - - Card matchedCard = findMatchingCard(cards, resCardNo); - if (matchedCard != null) { - approval.assignCard(matchedCard); - matchedApprovals.add(approval); - } else { - log.warn("승인내역의 카드번호({})와 일치하는 카드를 찾을 수 없어 스킵합니다.", resCardNo); - } - } - // 저장 - if (!matchedApprovals.isEmpty()) { - cardApprovalRepository.bulkInsert(matchedApprovals); - log.info("카드 승인내역 Bulk Insert 완료 - {}건 저장 (총 조회: {}건)", matchedApprovals.size(), approvals.size()); - } - } - - private String extractResCardNo(CardApproval approval) { - try { - JsonNode root = objectMapper.readTree(approval.getRawJson()); - if (root.has("resCardNo")) { - return root.get("resCardNo").asText(); - } - } catch (JsonProcessingException e) { - log.error("JSON 파싱 실패: {}", e.getMessage()); - } - return null; - } - - private Card findMatchingCard(List cards, String resCardNo) { - // 1순위: 정확히 일치 - for (Card card : cards) { - if (resCardNo.equals(card.getCardNoMasked())) { - return card; - } - } - - // 2순위: 뒤 4자리 일치 - if (resCardNo.length() >= 4) { - String last4 = resCardNo.substring(resCardNo.length() - 4); - for (Card card : cards) { - String cardNo = card.getCardNoMasked(); - if (cardNo != null && cardNo.length() >= 4 && cardNo.endsWith(last4)) { - return card; - } - } - } - - return null; + cardApprovalRepository.bulkInsert(approvals); + log.info("카드 승인내역 Bulk Insert 완료 - {}건", approvals.size()); } } diff --git a/src/main/java/org/umc/valuedi/domain/asset/service/command/worker/AssetFetchWorker.java b/src/main/java/org/umc/valuedi/domain/asset/service/command/worker/AssetFetchWorker.java new file mode 100644 index 00000000..be48490e --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/service/command/worker/AssetFetchWorker.java @@ -0,0 +1,108 @@ +package org.umc.valuedi.domain.asset.service.command.worker; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.entity.BankTransaction; +import org.umc.valuedi.domain.asset.entity.Card; +import org.umc.valuedi.domain.asset.entity.CardApproval; +import org.umc.valuedi.domain.asset.repository.bank.bankTransaction.BankTransactionRepository; +import org.umc.valuedi.domain.asset.repository.card.card.CardRepository; +import org.umc.valuedi.domain.asset.repository.card.cardApproval.CardApprovalRepository; +import org.umc.valuedi.domain.connection.entity.CodefConnection; +import org.umc.valuedi.domain.connection.enums.BusinessType; +import org.umc.valuedi.domain.member.entity.Member; +import org.umc.valuedi.global.external.codef.service.CodefAssetService; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AssetFetchWorker { + + private final CodefAssetService codefAssetService; + private final BankTransactionRepository bankTransactionRepository; + private final CardApprovalRepository cardApprovalRepository; + private final CardRepository cardRepository; + + public record FetchResult( + CodefConnection connection, + LocalDate startDate, + List bankTransactions, + List cardApprovals, + boolean isSuccess + ) {} + + @Async("assetFetchExecutor") + public CompletableFuture fetchAndConvertData(CodefConnection connection, Member member) { + LocalDate today = LocalDate.now(); + LocalDate defaultStartDate = today.minusMonths(3); // 기본 시작일을 3개월 전으로 설정 + LocalDate overallStartDate = today; // 전체 기관의 시작일 기록용 + + try { + if (connection.getBusinessType() == BusinessType.BK) { + List accounts = connection.getBankAccountList(); + List allTransactions = new ArrayList<>(); + + for (BankAccount account : accounts) { + try { + // 계좌별로 최적의 startDate 계산 (없으면 3개월 전) + LocalDate accountStartDate = bankTransactionRepository.findLatestTransactionDateByAccount(account) + .orElse(defaultStartDate); + + // 계좌별로 API 호출 + List fetched = codefAssetService.getBankTransactions(connection, account, accountStartDate, today); + allTransactions.addAll(fetched); + + // 가장 이른 시작일을 기록 + if (accountStartDate.isBefore(overallStartDate)) { + overallStartDate = accountStartDate; + } + } catch (Exception e) { + log.warn("[ASSET-FETCH-WORKER] 은행 계좌 거래내역 수집 중 오류 발생. 계좌: {}, 기관: {}, 회원 ID: {}", + account.getAccountDisplay(), connection.getOrganization(), member.getId(), e); + } + } + return CompletableFuture.completedFuture(new FetchResult(connection, overallStartDate, allTransactions, Collections.emptyList(), true)); + + } else if (connection.getBusinessType() == BusinessType.CD) { + // 1. 카드 목록 먼저 동기화 (필수) + List fetchedCards = codefAssetService.getCards(connection); + List savedCards = Collections.emptyList(); + if (!fetchedCards.isEmpty()) { + List existingCards = cardRepository.findAllByCodefConnection(connection); + + List cardsToSave = fetchedCards.stream().map(newCard -> { + return existingCards.stream() + .filter(oldCard -> oldCard.getCardNoMasked().equals(newCard.getCardNoMasked())) + .findFirst() + .map(oldCard -> oldCard) // 정보 갱신 필요 시 여기서 수행 + .orElse(newCard); + }).toList(); + + // saveAll의 결과(영속화된 객체들) 저장 + savedCards = cardRepository.saveAll(cardsToSave); + } + + LocalDate cardStartDate = cardApprovalRepository.findLatestApprovalDateByMember(member) + .orElse(defaultStartDate); + + List fetched = codefAssetService.getCardApprovals(connection, savedCards, cardStartDate, today); + + return CompletableFuture.completedFuture(new FetchResult(connection, cardStartDate, Collections.emptyList(), fetched, true)); + } + } catch (Exception e) { + log.error("[ASSET-FETCH-WORKER] 자산 데이터 수집 비동기 작업 중 예측하지 못한 오류 발생. 기관: {}, 회원 ID: {}", + connection.getOrganization(), member.getId(), e); + return CompletableFuture.completedFuture(new FetchResult(connection, defaultStartDate, Collections.emptyList(), Collections.emptyList(), false)); + } + return CompletableFuture.completedFuture(new FetchResult(connection, defaultStartDate, Collections.emptyList(), Collections.emptyList(), true)); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/service/AssetQueryService.java b/src/main/java/org/umc/valuedi/domain/asset/service/query/AssetQueryService.java similarity index 82% rename from src/main/java/org/umc/valuedi/domain/asset/service/AssetQueryService.java rename to src/main/java/org/umc/valuedi/domain/asset/service/query/AssetQueryService.java index 19b810ae..fdd5a32d 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/service/AssetQueryService.java +++ b/src/main/java/org/umc/valuedi/domain/asset/service/query/AssetQueryService.java @@ -1,4 +1,4 @@ -package org.umc.valuedi.domain.asset.service; +package org.umc.valuedi.domain.asset.service.query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -9,8 +9,8 @@ import org.umc.valuedi.domain.asset.dto.res.CardResDTO; import org.umc.valuedi.domain.asset.entity.BankAccount; import org.umc.valuedi.domain.asset.entity.Card; -import org.umc.valuedi.domain.asset.repository.bank.BankAccountRepository; -import org.umc.valuedi.domain.asset.repository.card.CardRepository; +import org.umc.valuedi.domain.asset.repository.bank.bankAccount.BankAccountRepository; +import org.umc.valuedi.domain.asset.repository.card.card.CardRepository; import java.util.List; @@ -32,11 +32,11 @@ public BankResDTO.BankAccountListDTO getAllBankAccounts(Long memberId) { } /** - * 은행별 연동된 계좌 목록 조회 + * 은행별 연동된 계좌 및 목표 목록 조회 */ - public BankResDTO.BankAccountListDTO getBankAccountsByOrganization(Long memberId, String organization) { + public BankResDTO.BankAssetResponse getBankAccountsByOrganization(Long memberId, String organization) { List accounts = bankAccountRepository.findAllByMemberIdAndOrganization(memberId, organization); - return AssetConverter.toBankAccountListDTO(accounts); + return AssetConverter.toBankAssetResponse(organization, accounts); } /** diff --git a/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionCommandService.java b/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionCommandService.java index f317da3a..07fb52e8 100644 --- a/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionCommandService.java +++ b/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionCommandService.java @@ -6,9 +6,11 @@ import org.springframework.transaction.annotation.Transactional; import org.umc.valuedi.domain.connection.dto.req.ConnectionReqDTO; import org.umc.valuedi.domain.connection.entity.CodefConnection; +import org.umc.valuedi.domain.connection.enums.BusinessType; import org.umc.valuedi.domain.connection.exception.ConnectionException; import org.umc.valuedi.domain.connection.exception.code.ConnectionErrorCode; import org.umc.valuedi.domain.connection.repository.CodefConnectionRepository; +import org.umc.valuedi.domain.goal.repository.GoalRepository; import org.umc.valuedi.global.external.codef.service.CodefAccountService; @Slf4j @@ -19,6 +21,7 @@ public class ConnectionCommandService { private final CodefAccountService codefAccountService; private final CodefConnectionRepository codefConnectionRepository; + private final GoalRepository goalRepository; /** * 금융사 계정 연동 @@ -45,10 +48,12 @@ public void disconnect(Long memberId, Long connectionId) { connection.getBusinessType() ); - // connection Soft Delete (Cascade에 의해 하위 계좌/카드도 Soft Delete 됨) + //하위 계좌/카드도 Soft Delete codefConnectionRepository.delete(connection); - // TODO: [추후 구현] 은행 계좌와 연결된 목표(Goal) Soft Delete 처리 로직 추가 필요 - // if (connection.getBusinessType() == BusinessType.BANK) { ... } + if (connection.getBusinessType() == BusinessType.BK) { + // 은행 계좌와 연결된 목표 Soft Delete 처리 (서브쿼리 사용) + goalRepository.softDeleteGoalsByConnectionId(connection.getId()); + } } } diff --git a/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionEventListener.java b/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionEventListener.java index 82032a02..ff718ddf 100644 --- a/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionEventListener.java +++ b/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionEventListener.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -import org.umc.valuedi.domain.asset.service.AssetSyncService; +import org.umc.valuedi.domain.asset.service.command.AssetSyncService; import org.umc.valuedi.domain.connection.dto.event.ConnectionSuccessEvent; @Slf4j @@ -16,7 +16,7 @@ public class ConnectionEventListener { private final AssetSyncService assetSyncService; - @Async("assetSyncExecutor") + @Async("assetFetchExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleConnectionSuccess(ConnectionSuccessEvent event) { log.info("금융사 연동 성공 이벤트 수신 - Connection ID: {}, Organization: {}", diff --git a/src/main/java/org/umc/valuedi/domain/goal/controller/GoalController.java b/src/main/java/org/umc/valuedi/domain/goal/controller/GoalController.java index c29b0505..685a18db 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/controller/GoalController.java +++ b/src/main/java/org/umc/valuedi/domain/goal/controller/GoalController.java @@ -5,18 +5,21 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.umc.valuedi.domain.goal.dto.request.GoalCreateRequestDto; +import org.umc.valuedi.domain.goal.dto.request.GoalLinkAccountRequestDto; import org.umc.valuedi.domain.goal.dto.request.GoalUpdateRequestDto; -import org.umc.valuedi.domain.goal.dto.response.GoalCreateResponseDto; -import org.umc.valuedi.domain.goal.dto.response.GoalDetailResponseDto; -import org.umc.valuedi.domain.goal.dto.response.GoalListResponseDto; -import org.umc.valuedi.domain.goal.dto.response.GoalActiveCountResponseDto; +import org.umc.valuedi.domain.goal.dto.response.*; import org.umc.valuedi.domain.goal.enums.GoalStatus; import org.umc.valuedi.domain.goal.enums.GoalSort; import org.umc.valuedi.domain.goal.exception.code.GoalSuccessCode; +import org.umc.valuedi.domain.goal.service.command.GoalAccountCommandService; import org.umc.valuedi.domain.goal.service.command.GoalCommandService; +import org.umc.valuedi.domain.goal.service.query.GoalAccountQueryService; +import org.umc.valuedi.domain.goal.service.query.GoalLedgerQueryService; import org.umc.valuedi.domain.goal.service.query.GoalListQueryService; import org.umc.valuedi.domain.goal.service.query.GoalQueryService; +import org.umc.valuedi.domain.ledger.dto.response.LedgerListResponse; import org.umc.valuedi.global.apiPayload.ApiResponse; +import org.umc.valuedi.global.security.annotation.CurrentMember; @RestController @RequiredArgsConstructor @@ -26,23 +29,27 @@ public class GoalController implements GoalControllerDocs{ private final GoalCommandService goalService; private final GoalQueryService goalQueryService; private final GoalListQueryService goalListQueryService; + private final GoalAccountQueryService goalAccountQueryService; + private final GoalAccountCommandService goalAccountCommandService; + private final GoalLedgerQueryService goalLedgerQueryService; // 목표 추가 @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createGoal( + @CurrentMember Long memberId, @RequestBody @Valid GoalCreateRequestDto req ) { return ApiResponse.onSuccess( GoalSuccessCode.GOAL_CREATED, - goalService.createGoal(req) + goalService.createGoal(memberId, req) ); } // 전체 목표 조회 (진행/완료/취소 분리) @GetMapping public ApiResponse getGoals( - @RequestParam Long memberId, + @CurrentMember Long memberId, @RequestParam(defaultValue = "ACTIVE") GoalStatus status, @RequestParam(defaultValue = "TIME_DESC") GoalSort sort, @RequestParam(required = false) Integer limit @@ -56,21 +63,23 @@ public ApiResponse getGoals( // 목표 상세 조회 @GetMapping("/{goalId}") public ApiResponse getGoalDetail( + @CurrentMember Long memberId, @PathVariable Long goalId ) { return ApiResponse.onSuccess( GoalSuccessCode.GOAL_DETAIL_FETCHED, - goalQueryService.getGoalDetail(goalId) + goalQueryService.getGoalDetail(memberId, goalId) ); } // 목표 수정 @PatchMapping("/{goalId}") public ApiResponse updateGoal( + @CurrentMember Long memberId, @PathVariable Long goalId, @RequestBody @Valid GoalUpdateRequestDto req ) { - goalService.updateGoal(goalId, req); + goalService.updateGoal(memberId, goalId, req); return ApiResponse.onSuccess( GoalSuccessCode.GOAL_UPDATED, null @@ -80,9 +89,10 @@ public ApiResponse updateGoal( // 목표 삭제 @DeleteMapping("/{goalId}") public ApiResponse deleteGoal( + @CurrentMember Long memberId, @PathVariable Long goalId ) { - goalService.deleteGoal(goalId); + goalService.deleteGoal(memberId, goalId); return ApiResponse.onSuccess( GoalSuccessCode.GOAL_DELETED, null @@ -92,11 +102,63 @@ public ApiResponse deleteGoal( // 목표 개수 조회 @GetMapping("/count") public ApiResponse getActiveGoalCount( - @RequestParam Long memberId + @CurrentMember Long memberId ) { return ApiResponse.onSuccess( GoalSuccessCode.GOAL_ACTIVE_COUNT_FETCHED, goalQueryService.getActiveGoalCount(memberId) ); } + + + // 목표 없는 계좌 조회 + @GetMapping("/accounts") + public ApiResponse getUnlinkedAccounts( + @CurrentMember Long memberId + + ) { + return ApiResponse.onSuccess( + GoalSuccessCode.GOAL_UNLINKED_ACCOUNTS_FETCHED, + goalAccountQueryService.getUnlinkedAccounts(memberId) + ); + } + + // 목표에 계좌 재연결 + @PutMapping("/{goalId}/linked-accounts") + public ApiResponse linkAccountToGoal( + @CurrentMember Long memberId, + @PathVariable Long goalId, + @RequestBody @Valid GoalLinkAccountRequestDto req + ) { + goalAccountCommandService.setLinkedAccount(memberId, goalId, req.accountId()); + return ApiResponse.onSuccess( + GoalSuccessCode.GOAL_ACCOUNT_LINKED, + null + ); + } + + // 주요 목표 조회(홈화면) + @GetMapping("/primary") + public ApiResponse getPrimaryGoals( + @CurrentMember Long memberId + ) { + return ApiResponse.onSuccess( + GoalSuccessCode.GOAL_LIST_FETCHED, + goalQueryService.getPrimaryGoals(memberId) + ); + } + + //목표 거래내역 조회 + @GetMapping("/{goalId}/ledgers") + public ApiResponse getGoalLedgers( + @CurrentMember Long memberId, + @PathVariable Long goalId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess( + GoalSuccessCode.GOAL_LEDGER_LIST_FETCHED, + goalLedgerQueryService.getGoalLedgerTransactions(memberId, goalId, page, size) + ); + } } diff --git a/src/main/java/org/umc/valuedi/domain/goal/controller/GoalControllerDocs.java b/src/main/java/org/umc/valuedi/domain/goal/controller/GoalControllerDocs.java index 810da999..b11b7445 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/controller/GoalControllerDocs.java +++ b/src/main/java/org/umc/valuedi/domain/goal/controller/GoalControllerDocs.java @@ -6,62 +6,62 @@ import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.umc.valuedi.domain.goal.dto.request.GoalCreateRequestDto; +import org.umc.valuedi.domain.goal.dto.request.GoalLinkAccountRequestDto; import org.umc.valuedi.domain.goal.dto.request.GoalUpdateRequestDto; -import org.umc.valuedi.domain.goal.dto.response.GoalActiveCountResponseDto; -import org.umc.valuedi.domain.goal.dto.response.GoalCreateResponseDto; -import org.umc.valuedi.domain.goal.dto.response.GoalDetailResponseDto; -import org.umc.valuedi.domain.goal.dto.response.GoalListResponseDto; +import org.umc.valuedi.domain.goal.dto.response.*; import org.umc.valuedi.domain.goal.enums.GoalStatus; import org.umc.valuedi.domain.goal.enums.GoalSort; +import org.umc.valuedi.domain.ledger.dto.response.LedgerListResponse; +import org.umc.valuedi.global.security.annotation.CurrentMember; @Tag(name = "Goal", description = "목표(Goal) 생성/조회/수정/삭제 API") public interface GoalControllerDocs { @Operation( summary = "목표 추가 API", - description = "목표 이름, 시작일, 종료일, 목표 금액을 입력받아 목표를 생성합니다. (계좌 연동은 추후 추가)" + description = "로그인한 사용자가 목표를 생성합니다." ) @ApiResponses({ @ApiResponse(responseCode = "201", description = "생성 성공"), @ApiResponse(responseCode = "400", description = "검증 실패 (날짜 범위 오류, targetAmount 범위 오류 등)"), - @ApiResponse(responseCode = "404", description = "회원이 존재하지 않음"), + @ApiResponse(responseCode = "404", description = "회원 또는 계좌가 존재하지 않음"), @ApiResponse(responseCode = "500", description = "서버 오류") }) org.umc.valuedi.global.apiPayload.ApiResponse createGoal( + @Parameter(hidden = true) @CurrentMember Long memberId, @Valid @RequestBody GoalCreateRequestDto req ); @Operation( summary = "목표 목록 조회 API", - description = "사용자의 전체 목표 목록을 조회합니다." + description = "로그인한 사용자의 목표 목록을 조회합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "400", description = "status 파라미터 타입 오류"), + @ApiResponse(responseCode = "400", description = "status/sort 파라미터 오류"), @ApiResponse(responseCode = "404", description = "회원이 존재하지 않음"), @ApiResponse(responseCode = "500", description = "서버 오류") }) org.umc.valuedi.global.apiPayload.ApiResponse getGoals( - @Parameter(description = "회원 ID", example = "1", required = true) - @RequestParam Long memberId, - - @Parameter(description = "목표 상태", example = "ACTIVE", required = true) - @RequestParam GoalStatus status, + @Parameter(hidden = true) @CurrentMember Long memberId, - @Parameter(description = "목표 정렬", example = "TIME_DESC", required = true) - @RequestParam GoalSort sort, + @Parameter(description = "목표 상태", example = "ACTIVE") + @RequestParam(defaultValue = "ACTIVE") GoalStatus status, - @Parameter(description = "표시할 목표 수", example = "3", required = true) - @RequestParam Integer limit + @Parameter(description = "목표 정렬", example = "TIME_DESC") + @RequestParam(defaultValue = "TIME_DESC") GoalSort sort, + @Parameter(description = "표시할 목표 수(없으면 전체)", example = "3") + @RequestParam(required = false) Integer limit ); @Operation( summary = "목표 상세 조회 API", - description = "goalId에 해당하는 목표의 상세 정보를 조회합니다." + description = "로그인한 사용자의 특정 목표(goalId)에 대한 상세 정보를 조회합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), @@ -69,13 +69,15 @@ org.umc.valuedi.global.apiPayload.ApiResponse getGoals( @ApiResponse(responseCode = "500", description = "서버 오류") }) org.umc.valuedi.global.apiPayload.ApiResponse getGoalDetail( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Parameter(description = "목표 ID", example = "10", required = true) - Long goalId + @PathVariable Long goalId ); @Operation( summary = "목표 수정 API", - description = "goalId에 해당하는 목표의 제목/기간/금액을 수정합니다." + description = "로그인한 사용자의 특정 목표(goalId)의 제목/기간/금액 등을 수정합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "수정 성공"), @@ -84,15 +86,17 @@ org.umc.valuedi.global.apiPayload.ApiResponse getGoalDeta @ApiResponse(responseCode = "500", description = "서버 오류") }) org.umc.valuedi.global.apiPayload.ApiResponse updateGoal( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Parameter(description = "목표 ID", example = "10", required = true) - Long goalId, + @PathVariable Long goalId, @Valid @RequestBody GoalUpdateRequestDto req ); @Operation( summary = "목표 삭제 API", - description = "goalId에 해당하는 목표를 취소 처리합니다." + description = "로그인한 사용자의 특정 목표(goalId)를 삭제(soft delete) 처리합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "삭제 성공"), @@ -100,21 +104,90 @@ org.umc.valuedi.global.apiPayload.ApiResponse updateGoal( @ApiResponse(responseCode = "500", description = "서버 오류") }) org.umc.valuedi.global.apiPayload.ApiResponse deleteGoal( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Parameter(description = "목표 ID", example = "10", required = true) - Long goalId + @PathVariable Long goalId ); @Operation( summary = "진행 중인 목표 개수 조회 API", - description = "사용자의 현재 진행 중(ACTIVE) 목표의 개수를 반환합니다." + description = "로그인한 사용자의 현재 진행 중(ACTIVE) 목표 개수를 반환합니다." ) - @io.swagger.v3.oas.annotations.responses.ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "회원이 존재하지 않음"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "회원이 존재하지 않음"), + @ApiResponse(responseCode = "500", description = "서버 오류") }) org.umc.valuedi.global.apiPayload.ApiResponse getActiveGoalCount( - @Parameter(description = "회원 ID", example = "1", required = true) - @RequestParam Long memberId + @Parameter(hidden = true) @CurrentMember Long memberId + ); + + @Operation( + summary = "목표에 연결되지 않은 계좌 목록 조회 API", + description = "로그인한 사용자의 계좌 중 아직 어떤 목표에도 연결되지 않은 계좌 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "회원이 존재하지 않음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + org.umc.valuedi.global.apiPayload.ApiResponse getUnlinkedAccounts( + @Parameter(hidden = true) @CurrentMember Long memberId + ); + + @Operation( + summary = "목표-계좌 연결 API", + description = "특정 목표(goalId)에 로그인한 사용자의 계좌(accountId)를 1:1로 연결합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "연결 성공"), + @ApiResponse(responseCode = "400", description = "이미 연결된 목표/계좌 등 요청 오류"), + @ApiResponse(responseCode = "404", description = "목표 또는 계좌가 존재하지 않음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + org.umc.valuedi.global.apiPayload.ApiResponse linkAccountToGoal( + @Parameter(hidden = true) @CurrentMember Long memberId, + + @Parameter(description = "목표 ID", example = "10", required = true) + @PathVariable Long goalId, + + @Valid @RequestBody GoalLinkAccountRequestDto req + ); + + @Operation( + summary = "홈화면 목표 목록 조회 API", + description = "로그인한 사용자의 진행 중(ACTIVE) 목표를 생성일 최신순으로 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "회원이 존재하지 않음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + org.umc.valuedi.global.apiPayload.ApiResponse getPrimaryGoals( + @Parameter(hidden = true) @CurrentMember Long memberId + ); + + @Operation( + summary = "목표 거래내역 조회 API", + description = "로그인한 사용자의 특정 목표(goalId)에 연결된 계좌의 거래내역을 페이지네이션으로 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "page/size 파라미터 오류"), + @ApiResponse(responseCode = "404", description = "목표가 존재하지 않음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + org.umc.valuedi.global.apiPayload.ApiResponse getGoalLedgers( + @Parameter(hidden = true) @CurrentMember Long memberId, + + @Parameter(description = "목표 ID", example = "10", required = true) + @PathVariable Long goalId, + + @Parameter(description = "페이지 번호(0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기", example = "20") + @RequestParam(defaultValue = "20") int size ); } diff --git a/src/main/java/org/umc/valuedi/domain/goal/converter/GoalAccountConverter.java b/src/main/java/org/umc/valuedi/domain/goal/converter/GoalAccountConverter.java new file mode 100644 index 00000000..52cd27bf --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/converter/GoalAccountConverter.java @@ -0,0 +1,21 @@ +package org.umc.valuedi.domain.goal.converter; + +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.goal.dto.response.GoalAccountResDto; + +import java.util.List; + +public class GoalAccountConverter { + + public static GoalAccountResDto.UnlinkedBankAccountListDTO toUnlinkedListDTO(List accounts) { + List items = accounts.stream() + .map(a -> new GoalAccountResDto.UnlinkedBankAccountDTO( + a.getId(), + a.getAccountName(), + a.getAccountDisplay() + )) + .toList(); + + return new GoalAccountResDto.UnlinkedBankAccountListDTO(items); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/converter/GoalConverter.java b/src/main/java/org/umc/valuedi/domain/goal/converter/GoalConverter.java index 86822407..dd3791e2 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/converter/GoalConverter.java +++ b/src/main/java/org/umc/valuedi/domain/goal/converter/GoalConverter.java @@ -1,29 +1,32 @@ package org.umc.valuedi.domain.goal.converter; +import org.umc.valuedi.domain.asset.entity.BankAccount; import org.umc.valuedi.domain.goal.constant.GoalStyleCatalog; import org.umc.valuedi.domain.goal.dto.request.GoalCreateRequestDto; import org.umc.valuedi.domain.goal.dto.request.GoalUpdateRequestDto; +import org.umc.valuedi.domain.goal.dto.response.GoalCreateResponseDto; import org.umc.valuedi.domain.goal.dto.response.GoalDetailResponseDto; import org.umc.valuedi.domain.goal.dto.response.GoalListResponseDto; +import org.umc.valuedi.domain.goal.dto.response.GoalPrimaryListResponseDto; import org.umc.valuedi.domain.goal.entity.Goal; import org.umc.valuedi.domain.goal.enums.GoalStatus; import org.umc.valuedi.domain.member.entity.Member; import java.time.LocalDate; import java.time.temporal.ChronoUnit; +import java.util.List; public class GoalConverter { - private GoalConverter() {} - - public static Goal toEntity(Member member, GoalCreateRequestDto req) { + public static Goal toEntity(Member member,BankAccount bankAccount, GoalCreateRequestDto req, Long startAmount) { return Goal.builder() .member(member) - .accountId(null) // 계좌 연동 되면 채우기 + .bankAccount(bankAccount) .title(req.title()) .startDate(req.startDate()) .endDate(req.endDate()) .targetAmount(req.targetAmount()) + .startAmount(startAmount) .status(GoalStatus.ACTIVE) .completedAt(null) .color(GoalStyleCatalog.normalizeColor(req.colorCode())) @@ -52,34 +55,70 @@ public static void applyPatch(Goal goal, GoalUpdateRequestDto req) { goal.changeIcon(req.iconId()); } + private record AccountInfo(String bankName, String accountNumber) {} + + private static AccountInfo extractAccountInfo(BankAccount bankAccount) { + if (bankAccount == null) return null; + + String bankName = null; + if (bankAccount.getCodefConnection() != null) { + bankName = bankAccount.getCodefConnection().getOrganization(); + } + String accountNumber = bankAccount.getAccountDisplay(); + return new AccountInfo(bankName, accountNumber); + } + + + public static GoalCreateResponseDto toCreateDto(Goal goal) { + long remainingDays = calcRemainingDays(goal.getEndDate()); + + GoalCreateResponseDto.AccountDto accountDto = null; + var info = extractAccountInfo(goal.getBankAccount()); + if (info != null) { + accountDto = new GoalCreateResponseDto.AccountDto(info.bankName(), info.accountNumber()); + } + + return new GoalCreateResponseDto( + goal.getId(), + goal.getTitle(), + goal.getTargetAmount(), + goal.getStartAmount(), + goal.getStartDate(), + goal.getEndDate(), + remainingDays, + accountDto, + goal.getIcon() + ); + } + public static GoalListResponseDto.GoalSummaryDto toSummaryDto( Goal goal, Long savedAmount, int achievementRate ) { - Long remainingAmount = Math.max(goal.getTargetAmount() - savedAmount, 0); Long remainingDays = calcRemainingDays(goal.getEndDate()); return new GoalListResponseDto.GoalSummaryDto( goal.getId(), goal.getTitle(), - remainingAmount, + savedAmount, remainingDays, achievementRate, - null, // 계좌 연동 되면 채우기 goal.getStatus(), goal.getColor(), goal.getIcon() ); } - public static GoalDetailResponseDto toDetailDto( - Goal goal, - Long savedAmount, - int achievementRate - ) { + public static GoalDetailResponseDto toDetailDto(Goal goal, Long savedAmount, int achievementRate) { long remainingDays = calcRemainingDays(goal.getEndDate()); + GoalDetailResponseDto.AccountDto accountDto = null; + var info = extractAccountInfo(goal.getBankAccount()); + if (info != null) { + accountDto = new GoalDetailResponseDto.AccountDto(info.bankName(), info.accountNumber()); + } + return new GoalDetailResponseDto( goal.getId(), goal.getTitle(), @@ -87,7 +126,7 @@ public static GoalDetailResponseDto toDetailDto( goal.getTargetAmount(), remainingDays, achievementRate, - null, // 계좌 연동 되면 채우기 + accountDto, goal.getStatus(), goal.getColor(), goal.getIcon() @@ -99,4 +138,18 @@ private static long calcRemainingDays(LocalDate endDate) { long days = ChronoUnit.DAYS.between(today, endDate); return Math.max(days, 0); } + + public static GoalPrimaryListResponseDto toPrimaryListResponse(List goals) { + return new GoalPrimaryListResponseDto( + goals.stream() + .map(goal -> new GoalPrimaryListResponseDto.GoalPrimarySummaryDto( + goal.getId(), // goalId + goal.getTitle(), + goal.getTargetAmount(), + goal.getIcon() + )) + .toList() + ); + } + } diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalCreateRequestDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalCreateRequestDto.java index 5706f743..2083669d 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalCreateRequestDto.java +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalCreateRequestDto.java @@ -11,8 +11,9 @@ @Schema(description = "목표 추가") public record GoalCreateRequestDto( - @Schema(description = "회원 ID", example = "1") - @NotNull Long memberId, + @Schema(description = "연결할 계좌 ID", example = "1") + @NotNull + Long bankAccountId, @Schema(description = "목표 이름(최대 12자)", example = "유럽 여행 자금") @NotNull @@ -26,10 +27,12 @@ public record GoalCreateRequestDto( @Schema(description = "목표 종료일 (YYYY-MM-DD)", example = "2026-08-31") @NotNull LocalDate endDate, + @Schema(description = "목표 금액(원 단위, 1 이상)", example = "3000000", minimum = "1") @NotNull(message = "targetAmount는 필수입니다.") @Min(value = 1, message = "targetAmount는 1 이상이어야 합니다.") Long targetAmount, + @Schema(description = "색상 코드(HEX)", example = "FF6363") @NotBlank(message = "colorCode는 필수입니다.") String colorCode, @@ -38,4 +41,4 @@ public record GoalCreateRequestDto( @NotNull(message = "iconId는 필수입니다.") Integer iconId -) {} +) {} \ No newline at end of file diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalLinkAccountRequestDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalLinkAccountRequestDto.java new file mode 100644 index 00000000..6340274d --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalLinkAccountRequestDto.java @@ -0,0 +1,12 @@ +package org.umc.valuedi.domain.goal.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "목표-계좌 연결 요청") +public record GoalLinkAccountRequestDto( + + @Schema(description = "연결할 계좌 ID", example = "12") + @NotNull(message = "accountId는 필수입니다.") + Long accountId +) {} diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalUpdateRequestDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalUpdateRequestDto.java index e1416a2e..d7cad906 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalUpdateRequestDto.java +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalUpdateRequestDto.java @@ -35,4 +35,4 @@ public record GoalUpdateRequestDto( @NotNull(message = "iconId는 필수입니다.") Integer iconId -) {} +) {} \ No newline at end of file diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalAccountResDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalAccountResDto.java new file mode 100644 index 00000000..f526b16a --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalAccountResDto.java @@ -0,0 +1,29 @@ +package org.umc.valuedi.domain.goal.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "목표와 연결되지 않은 계좌 목록 조회 응답") +public class GoalAccountResDto { + + @Schema(description = "목표와 연결되지 않은 단일 계좌 정보") + public record UnlinkedBankAccountDTO( + + @Schema(description = "계좌 ID", example = "12") + Long accountId, + + @Schema(description = "계좌명", example = "입출금통장") + String accountName, + + @Schema(description = "표시용 계좌번호(마스킹 포함 가능)", example = "110-****-1234") + String accountDisplay + ) {} + + @Schema(description = "목표와 연결되지 않은 계좌 목록") + public record UnlinkedBankAccountListDTO( + + @Schema(description = "계좌 목록") + List accounts + ) {} +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalCreateResponseDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalCreateResponseDto.java index 2b529053..d25040b6 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalCreateResponseDto.java +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalCreateResponseDto.java @@ -1,5 +1,47 @@ package org.umc.valuedi.domain.goal.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "목표 생성 응답") public record GoalCreateResponseDto( - Long goalId -) {} + + @Schema(description = "생성된 목표 ID", example = "10") + Long goalId, + + @Schema(description = "목표 제목", example = "여행 자금 모으기") + String title, + + @Schema(description = "목표 금액", example = "1000000") + Long targetAmount, + + @Schema(description = "시작 금액", example = "1000") + Long startAmount, + + @Schema(description = "시작일", example = "2026-01-01") + LocalDate startDate, + + @Schema(description = "종료일", example = "2026-03-01") + LocalDate endDate, + + @Schema(description = "남은 일수", example = "30") + Long remainingDays, + + @Schema(description = "연결 계좌 정보") + AccountDto account, + + @Schema(description = "아이콘 ID", example = "3") + Integer iconId + +) { + @Schema(description = "목표와 연결된 계좌 정보") + public record AccountDto( + + @Schema(description = "은행명", example = "신한은행") + String bankName, + + @Schema(description = "계좌번호", example = "110-123-456789") + String accountNumber + ) {} +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalDetailResponseDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalDetailResponseDto.java index 2d274ab1..363f6643 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalDetailResponseDto.java +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalDetailResponseDto.java @@ -1,21 +1,49 @@ package org.umc.valuedi.domain.goal.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import org.umc.valuedi.domain.goal.enums.GoalStatus; +@Schema(description = "목표 상세 조회 응답") public record GoalDetailResponseDto( + + @Schema(description = "목표 ID", example = "10") Long goalId, + + @Schema(description = "목표 제목", example = "여행 자금 모으기") String title, + + @Schema(description = "현재까지 모은 금액", example = "300000") Long savedAmount, + + @Schema(description = "목표 금액", example = "1000000") Long targetAmount, + + @Schema(description = "남은 일수", example = "30") Long remainingDays, + + @Schema(description = "달성률(0~100)", example = "30") Integer achievementRate, // 0~100 + + @Schema(description = "연결 계좌 정보") AccountDto account, + + @Schema(description = "목표 상태", example = "ACTIVE") GoalStatus status, + + @Schema(description = "색상 코드(# 제외)", example = "FF6363") String colorCode, + + @Schema(description = "아이콘 ID", example = "3") Integer iconId + ) { + @Schema(description = "목표와 연결된 계좌 정보") public record AccountDto( + + @Schema(description = "은행명", example = "신한은행") String bankName, + + @Schema(description = "계좌번호", example = "110-123-456789") String accountNumber ) {} } diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalListResponseDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalListResponseDto.java index 9ef18349..8bcefa5d 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalListResponseDto.java +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalListResponseDto.java @@ -1,22 +1,42 @@ package org.umc.valuedi.domain.goal.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import org.umc.valuedi.domain.goal.enums.GoalStatus; import java.util.List; +@Schema(description = "목표 목록 조회 응답") public record GoalListResponseDto( + + @Schema(description = "목표 요약 목록") List goals + ) { + @Schema(description = "목표 요약 정보") public record GoalSummaryDto( + + @Schema(description = "목표 ID", example = "10") Long goalId, + + @Schema(description = "목표 제목", example = "여행 자금 모으기") String title, - Long remainingAmount, + + @Schema(description = "모은 금액", example = "700000") + Long savedAmount, + + @Schema(description = "남은 일수", example = "30") Long remainingDays, - Integer achievementRate, // 0~100 - String bankName, // 계좌 연동 되면 구현 할 예정입당 + @Schema(description = "달성률(0~100)", example = "30") + Integer achievementRate, + + @Schema(description = "목표 상태", example = "ACTIVE") GoalStatus status, + + @Schema(description = "색상 코드(# 제외)", example = "FF6363") String colorCode, + + @Schema(description = "아이콘 ID", example = "3") Integer iconId ) {} diff --git a/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalPrimaryListResponseDto.java b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalPrimaryListResponseDto.java new file mode 100644 index 00000000..e7f8f5d3 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalPrimaryListResponseDto.java @@ -0,0 +1,30 @@ +package org.umc.valuedi.domain.goal.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Primary 목표 목록 조회 응답 (ACTIVE 최신순, 간단 조회)") +public record GoalPrimaryListResponseDto( + + @Schema(description = "Primary 목표 목록") + List goals + +) { + @Schema(description = "Primary 목표 요약 정보") + public record GoalPrimarySummaryDto( + + @Schema(description = "목표 ID", example = "10") + Long goalId, + + @Schema(description = "목표 제목", example = "여행 자금 모으기") + String title, + + @Schema(description = "목표 금액", example = "1000000") + Long targetAmount, + + @Schema(description = "아이콘 ID", example = "3") + Integer iconId + + ) {} +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/entity/Goal.java b/src/main/java/org/umc/valuedi/domain/goal/entity/Goal.java index bf2d9626..ae735b2e 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/entity/Goal.java +++ b/src/main/java/org/umc/valuedi/domain/goal/entity/Goal.java @@ -4,6 +4,7 @@ import lombok.*; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; +import org.umc.valuedi.domain.asset.entity.BankAccount; import org.umc.valuedi.domain.goal.enums.GoalStatus; import org.umc.valuedi.domain.member.entity.Member; import org.umc.valuedi.global.entity.BaseEntity; @@ -18,7 +19,7 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder @SQLRestriction("deleted_at IS NULL") -@SQLDelete(sql = "UPDATE goal SET deleted_at = NOW() WHERE id = ?") +@SQLDelete(sql = "UPDATE goal SET deleted_at = NOW(), bank_account_id = NULL WHERE id = ?") public class Goal extends BaseEntity { @Id @@ -29,8 +30,9 @@ public class Goal extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; - @Column(name = "id2", nullable = true) - private Long accountId; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bank_account_id", unique = true) + private BankAccount bankAccount; @Column(name = "title", nullable = false, length = 20) private String title; @@ -44,6 +46,9 @@ public class Goal extends BaseEntity { @Column(name = "target_amount", nullable = false) private Long targetAmount; + @Column(name = "start_amount", nullable = false) + private Long startAmount; + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 20) private GoalStatus status; @@ -75,6 +80,7 @@ public void changeEndDate(LocalDate endDate) { public void changeTargetAmount(Long targetAmount) { this.targetAmount = targetAmount; } + public void changeStartAmount(Long startAmount) { this.startAmount = startAmount; } public void changeColor(String color) { this.color = color; } @@ -97,4 +103,12 @@ public void Fail() { this.completedAt = LocalDateTime.now(); } + public void linkBankAccount(BankAccount bankAccount) { + this.bankAccount = bankAccount; + } + + public void unlinkBankAccount() { + this.bankAccount = null; + } + } diff --git a/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalErrorCode.java b/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalErrorCode.java index 35ee5145..34fab75f 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalErrorCode.java +++ b/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalErrorCode.java @@ -14,8 +14,15 @@ public enum GoalErrorCode implements BaseErrorCode { GOAL_STATUS_INVALID(HttpStatus.NOT_FOUND, "GOAL404_3", "이미 완료되었거나 실패 처리된 목표입니다."), GOAL_COLOR_INVALID(HttpStatus.NOT_FOUND, "GOAL404_4", "존재하지 않는 목표 색상입니다."), GOAL_ICON_INVALID(HttpStatus.NOT_FOUND, "GOAL404_5", "존재하지 않는 목표 아이콘입니다."), + ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "GOAL404_6", "계좌를 찾을 수 없습니다."), INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "GOAL400_1", "시작일은 종료일보다 늦을 수 없습니다."), - INVALID_GOAL_LIST_STATUS(HttpStatus.BAD_REQUEST, "GOAL400_2", "목표 목록 조회 status는 ACTIVE 또는 COMPLETE만 가능합니다."); + INVALID_GOAL_LIST_STATUS(HttpStatus.BAD_REQUEST, "GOAL400_2", "목표 목록 조회 status는 ACTIVE 또는 COMPLETE만 가능합니다."), + GOAL_ALREADY_LINKED_ACCOUNT(HttpStatus.BAD_REQUEST, "GOAL400_3", "해당 목표는 이미 계좌가 연결되어 있습니다."), + ACCOUNT_ALREADY_LINKED_TO_GOAL(HttpStatus.BAD_REQUEST, "GOAL400_4", "해당 계좌는 이미 다른 목표에 연결되어 있습니다."), + GOAL_FORBIDDEN(HttpStatus.FORBIDDEN, "GOAL403_1", "해당 목표에 대한 권한이 없습니다."), + GOAL_ACCOUNT_INACTIVE(HttpStatus.GONE, "GOAL_410_1", "연결된 계좌가 비활성화(삭제)되어 목표 상세를 조회할 수 없습니다."), + GOAL_BANK_ACCOUNT_NOT_LINKED(HttpStatus.GONE, "GOAL_400_5", "해당 목표에 연결 된 계좌가 없습니다."); + private final HttpStatus status; diff --git a/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalSuccessCode.java b/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalSuccessCode.java index cd514ae3..ad5117d4 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalSuccessCode.java +++ b/src/main/java/org/umc/valuedi/domain/goal/exception/code/GoalSuccessCode.java @@ -14,7 +14,10 @@ public enum GoalSuccessCode implements BaseSuccessCode { GOAL_DETAIL_FETCHED(HttpStatus.OK, "GOAL200_2", "목표 상세 조회 성공"), GOAL_UPDATED(HttpStatus.OK, "GOAL200_3", "목표가 성공적으로 수정되었습니다."), GOAL_DELETED(HttpStatus.OK, "GOAL204_1", "목표가 성공적으로 삭제되었습니다."), - GOAL_ACTIVE_COUNT_FETCHED(HttpStatus.OK, "GOAL200_5", "진행 중인 목표 개수 조회 성공"); + GOAL_ACTIVE_COUNT_FETCHED(HttpStatus.OK, "GOAL200_5", "진행 중인 목표 개수 조회 성공"), + GOAL_UNLINKED_ACCOUNTS_FETCHED(HttpStatus.OK, "GOAL200_6", "연결 목표가 없는 계좌 조회 성공"), + GOAL_ACCOUNT_LINKED(HttpStatus.OK, "GOAL200_7", "계좌가 목표와 성공적으로 연결 되었습니다."), + GOAL_LEDGER_LIST_FETCHED(HttpStatus.OK, "GOAL200_8", "목표 가계부 거래내역 조회가 완료되었습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepository.java b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepository.java index eba06c00..8aaab912 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepository.java +++ b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepository.java @@ -1,24 +1,35 @@ package org.umc.valuedi.domain.goal.repository; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.umc.valuedi.domain.goal.entity.Goal; import org.umc.valuedi.domain.goal.enums.GoalStatus; -import org.springframework.data.domain.Pageable; import java.time.LocalDate; import java.util.List; -public interface GoalRepository extends JpaRepository { - - List findAllByMember_Id( Long memberId); +public interface GoalRepository extends JpaRepository, GoalRepositoryCustom { + List findAllByMember_Id(Long memberId); List findAllByMember_IdAndStatus(Long memberId, GoalStatus status); List findAllByMember_IdAndStatusIn(Long memberId, List statuses); long countByMember_IdAndStatus(Long memberId, GoalStatus status); List findAllByStatus(GoalStatus status); - List findAllByStatusAndEndDateLessThanEqual(GoalStatus status, LocalDate date); + List findAllByMember_IdAndStatus(Long memberId, GoalStatus status, Pageable pageable); List findAllByMember_IdAndStatusIn(Long memberId, List statuses, Pageable pageable); + boolean existsByBankAccount_Id(Long bankAccountId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Goal g SET g.deletedAt = CURRENT_TIMESTAMP " + + "WHERE g.bankAccount.id IN (" + + " SELECT ba.id FROM BankAccount ba " + + " WHERE ba.codefConnection.id = :connectionId" + + ")") + void softDeleteGoalsByConnectionId(@Param("connectionId") Long connectionId); } diff --git a/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryCustom.java b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryCustom.java new file mode 100644 index 00000000..d8aa73d7 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryCustom.java @@ -0,0 +1,17 @@ +package org.umc.valuedi.domain.goal.repository; + +import org.umc.valuedi.domain.goal.entity.Goal; +import org.umc.valuedi.domain.goal.enums.GoalStatus; + +import java.util.List; +import java.util.Optional; + +public interface GoalRepositoryCustom { + Optional findByIdWithDetails(Long goalId); + + List findAllByMemberIdAndStatusOrderByCreatedAtDesc(Long memberId, GoalStatus status); + + List findLinkedBankAccountIdsByMemberId(Long memberId); + + Optional findByIdAndMemberId(Long goalId, Long memberId); +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryImpl.java b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryImpl.java new file mode 100644 index 00000000..a9f81798 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryImpl.java @@ -0,0 +1,68 @@ +package org.umc.valuedi.domain.goal.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.umc.valuedi.domain.goal.entity.Goal; +import org.umc.valuedi.domain.goal.enums.GoalStatus; + +import java.util.List; +import java.util.Optional; + +import static org.umc.valuedi.domain.asset.entity.QBankAccount.bankAccount; +import static org.umc.valuedi.domain.connection.entity.QCodefConnection.codefConnection; +import static org.umc.valuedi.domain.goal.entity.QGoal.goal; + +@RequiredArgsConstructor +public class GoalRepositoryImpl implements GoalRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findByIdWithDetails(Long goalId) { + Goal result = queryFactory + .selectFrom(goal) + .leftJoin(goal.bankAccount, bankAccount).fetchJoin() + .leftJoin(bankAccount.codefConnection, codefConnection).fetchJoin() + .where(goal.id.eq(goalId)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findAllByMemberIdAndStatusOrderByCreatedAtDesc(Long memberId, GoalStatus status) { + return queryFactory + .selectFrom(goal) + .where( + goal.member.id.eq(memberId), + goal.status.eq(status) + ) + .orderBy(goal.createdAt.desc()) + .fetch(); + } + + @Override + public List findLinkedBankAccountIdsByMemberId(Long memberId) { + return queryFactory + .select(goal.bankAccount.id) + .from(goal) + .where( + goal.member.id.eq(memberId), + goal.bankAccount.isNotNull() + ) + .fetch(); + } + + @Override + public Optional findByIdAndMemberId(Long goalId, Long memberId) { + Goal result = queryFactory + .selectFrom(goal) + .where( + goal.id.eq(goalId), + goal.member.id.eq(memberId) + ) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/service/command/GoalAccountCommandService.java b/src/main/java/org/umc/valuedi/domain/goal/service/command/GoalAccountCommandService.java new file mode 100644 index 00000000..7fd8120d --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/service/command/GoalAccountCommandService.java @@ -0,0 +1,47 @@ +package org.umc.valuedi.domain.goal.service.command; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.repository.bank.bankAccount.BankAccountRepository; +import org.umc.valuedi.domain.goal.entity.Goal; +import org.umc.valuedi.domain.goal.exception.GoalException; +import org.umc.valuedi.domain.goal.exception.code.GoalErrorCode; +import org.umc.valuedi.domain.goal.repository.GoalRepository; + +@Service +@RequiredArgsConstructor +@Transactional +public class GoalAccountCommandService { + + private final GoalRepository goalRepository; + private final BankAccountRepository bankAccountRepository; + + public void setLinkedAccount(Long memberId, Long goalId, Long accountId) { + + Goal goal = goalRepository.findByIdAndMemberId(goalId, memberId) + .orElseThrow(() -> new GoalException(GoalErrorCode.GOAL_NOT_FOUND)); + + BankAccount newAccount = bankAccountRepository.findByIdAndMemberId(accountId, memberId) + .orElseThrow(() -> new GoalException(GoalErrorCode.ACCOUNT_NOT_FOUND)); + + // 이미 같은 계좌가 연결돼 있으면 그냥 성공 + if (goal.getBankAccount() != null && goal.getBankAccount().getId().equals(accountId)) { + return; + } + + // 다른 Goal이 이 계좌를 쓰고 있으면 실패 + if (goalRepository.existsByBankAccount_Id(accountId)) { + throw new GoalException(GoalErrorCode.ACCOUNT_ALREADY_LINKED_TO_GOAL); + } + + try { + // 교체(기존 연결 덮어쓰기) + goal.linkBankAccount(newAccount); + } catch (DataIntegrityViolationException e) { + throw new GoalException(GoalErrorCode.ACCOUNT_ALREADY_LINKED_TO_GOAL); + } + } +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/service/command/GoalCommandService.java b/src/main/java/org/umc/valuedi/domain/goal/service/command/GoalCommandService.java index bcd6d95d..fbcd9e96 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/service/command/GoalCommandService.java +++ b/src/main/java/org/umc/valuedi/domain/goal/service/command/GoalCommandService.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.repository.bank.bankAccount.BankAccountRepository; import org.umc.valuedi.domain.goal.converter.GoalConverter; import org.umc.valuedi.domain.goal.dto.request.GoalCreateRequestDto; import org.umc.valuedi.domain.goal.dto.request.GoalUpdateRequestDto; @@ -25,24 +27,38 @@ public class GoalCommandService { private final GoalRepository goalRepository; private final MemberRepository memberRepository; + private final BankAccountRepository bankAccountRepository; // 목표 생성 - public GoalCreateResponseDto createGoal(GoalCreateRequestDto req) { - Member member = memberRepository.findById(req.memberId()) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + public GoalCreateResponseDto createGoal(Long memberId, GoalCreateRequestDto req) { GoalValidator.validateDateRange(req.startDate(), req.endDate()); GoalValidator.validateStyle(req.colorCode(), req.iconId()); - Goal goal = GoalConverter.toEntity(member, req); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 내 계좌 + 활성 상태 검증 + BankAccount account = bankAccountRepository.findByIdAndMemberId(req.bankAccountId(), memberId) + .orElseThrow(() -> new GoalException(GoalErrorCode.ACCOUNT_NOT_FOUND)); + + // 이미 다른 목표가 이 계좌를 쓰고 있는지 검증 + if (goalRepository.existsByBankAccount_Id(account.getId())) { + throw new GoalException(GoalErrorCode.ACCOUNT_ALREADY_LINKED_TO_GOAL); + } + + Long startAmount = account.getBalanceAmount(); + + // Goal 엔티티 생성 시 bankAccount 포함 + Goal goal = GoalConverter.toEntity(member, account, req, startAmount); Goal saved = goalRepository.save(goal); - return new GoalCreateResponseDto(saved.getId()); + return GoalConverter.toCreateDto(saved); } // 목표 수정 - public void updateGoal(Long goalId, GoalUpdateRequestDto req) { - Goal goal = goalRepository.findById(goalId) + public void updateGoal(Long memberId, Long goalId, GoalUpdateRequestDto req) { + Goal goal = goalRepository.findByIdAndMemberId(goalId, memberId) .orElseThrow(() -> new GoalException(GoalErrorCode.GOAL_NOT_FOUND)); if (goal.getStatus() != GoalStatus.ACTIVE) { @@ -62,8 +78,8 @@ public void updateGoal(Long goalId, GoalUpdateRequestDto req) { } // 목표 삭제 - public void deleteGoal(Long goalId) { - Goal goal = goalRepository.findById(goalId) + public void deleteGoal(Long memberId, Long goalId) { + Goal goal = goalRepository.findByIdAndMemberId(goalId, memberId) .orElseThrow(() -> new GoalException(GoalErrorCode.GOAL_NOT_FOUND)); goalRepository.delete(goal); diff --git a/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalAccountQueryService.java b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalAccountQueryService.java new file mode 100644 index 00000000..b307e2b7 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalAccountQueryService.java @@ -0,0 +1,32 @@ +package org.umc.valuedi.domain.goal.service.query; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.repository.bank.bankAccount.BankAccountRepository; +import org.umc.valuedi.domain.goal.converter.GoalAccountConverter; +import org.umc.valuedi.domain.goal.dto.response.GoalAccountResDto; +import org.umc.valuedi.domain.goal.repository.GoalRepository; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GoalAccountQueryService { + + private final GoalRepository goalRepository; + private final BankAccountRepository bankAccountRepository; + + public GoalAccountResDto.UnlinkedBankAccountListDTO getUnlinkedAccounts(Long memberId) { + List linkedAccountIds = goalRepository.findLinkedBankAccountIdsByMemberId(memberId); + + List unlinked = bankAccountRepository.findUnlinkedByMemberId( + memberId, + linkedAccountIds + ); + + return GoalAccountConverter.toUnlinkedListDTO(unlinked); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalLedgerQueryService.java b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalLedgerQueryService.java new file mode 100644 index 00000000..872b1f34 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalLedgerQueryService.java @@ -0,0 +1,54 @@ +package org.umc.valuedi.domain.goal.service.query; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.umc.valuedi.domain.goal.entity.Goal; +import org.umc.valuedi.domain.goal.exception.GoalException; +import org.umc.valuedi.domain.goal.exception.code.GoalErrorCode; +import org.umc.valuedi.domain.goal.repository.GoalRepository; +import org.umc.valuedi.domain.ledger.converter.LedgerConverter; +import org.umc.valuedi.domain.ledger.dto.response.LedgerListResponse; +import org.umc.valuedi.domain.ledger.entity.LedgerEntry; +import org.umc.valuedi.domain.ledger.repository.LedgerQueryRepository; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GoalLedgerQueryService { + + private final GoalRepository goalRepository; + private final LedgerQueryRepository ledgerQueryRepository; + + public LedgerListResponse getGoalLedgerTransactions(Long memberId, Long goalId, int page, int size) { + Goal goal = goalRepository.findById(goalId) + .orElseThrow(() -> new GoalException(GoalErrorCode.GOAL_NOT_FOUND)); + + if (!goal.getMember().getId().equals(memberId)) { + throw new GoalException(GoalErrorCode.GOAL_FORBIDDEN); + } + + LocalDateTime from = goal.getStartDate().atStartOfDay(); + LocalDateTime to = goal.getEndDate().atTime(LocalTime.MAX); + + Page result = ledgerQueryRepository.searchByPeriodLatest( + memberId, + from, + to, + PageRequest.of(page, size) + ); + + return LedgerConverter.toLedgerListResponse( + result.getContent(), + page, + size, + result.getTotalElements() + ); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalListQueryService.java b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalListQueryService.java index a63d0da1..adc696ac 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalListQueryService.java +++ b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalListQueryService.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.umc.valuedi.domain.asset.entity.BankAccount; import org.umc.valuedi.domain.goal.converter.GoalConverter; import org.umc.valuedi.domain.goal.dto.response.GoalListResponseDto; import org.umc.valuedi.domain.goal.entity.Goal; @@ -71,14 +72,20 @@ private List findGoals(Long memberId, GoalStatus status) { } private List toSummaryDtos(List goals) { - long savedAmount = 0; // 계좌 연동 후 수정 return goals.stream() - .map(g -> GoalConverter.toSummaryDto( - g, - savedAmount, - achievementRateService.calculateRate(savedAmount, g.getTargetAmount()) - )) + .map(g -> { + BankAccount account = g.getBankAccount(); + + if (!account.getIsActive()) { + throw new GoalException(GoalErrorCode.GOAL_ACCOUNT_INACTIVE); + } + + long savedAmount = account.getBalanceAmount() - g.getStartAmount(); + int rate = achievementRateService.calculateRate(savedAmount, g.getTargetAmount()); + + return GoalConverter.toSummaryDto(g, savedAmount, rate); + }) .toList(); } diff --git a/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalQueryService.java b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalQueryService.java index 2b6ea6ed..355d7f5b 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalQueryService.java +++ b/src/main/java/org/umc/valuedi/domain/goal/service/query/GoalQueryService.java @@ -3,9 +3,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.umc.valuedi.domain.asset.entity.BankAccount; import org.umc.valuedi.domain.goal.converter.GoalConverter; import org.umc.valuedi.domain.goal.dto.response.GoalActiveCountResponseDto; import org.umc.valuedi.domain.goal.dto.response.GoalDetailResponseDto; +import org.umc.valuedi.domain.goal.dto.response.GoalPrimaryListResponseDto; import org.umc.valuedi.domain.goal.entity.Goal; import org.umc.valuedi.domain.goal.enums.GoalStatus; import org.umc.valuedi.domain.goal.exception.GoalException; @@ -16,6 +18,8 @@ import org.umc.valuedi.domain.member.exception.code.MemberErrorCode; import org.umc.valuedi.domain.member.repository.MemberRepository; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -26,11 +30,17 @@ public class GoalQueryService { private final GoalAchievementRateService achievementRateService; // 목표 상세 조회 - public GoalDetailResponseDto getGoalDetail(Long goalId) { - Goal goal = goalRepository.findById(goalId) + public GoalDetailResponseDto getGoalDetail(Long memberId, Long goalId) { + Goal goal = goalRepository.findByIdAndMemberId(goalId, memberId) .orElseThrow(() -> new GoalException(GoalErrorCode.GOAL_NOT_FOUND)); - long savedAmount = 0; // 계좌 연동 후 수정 + BankAccount account = goal.getBankAccount(); + + if (!account.getIsActive()) { + throw new GoalException(GoalErrorCode.GOAL_ACCOUNT_INACTIVE); + } + + long savedAmount = account.getBalanceAmount() - goal.getStartAmount(); int rate = achievementRateService.calculateRate(savedAmount, goal.getTargetAmount()); return GoalConverter.toDetailDto(goal, savedAmount, rate); @@ -45,4 +55,13 @@ public GoalActiveCountResponseDto getActiveGoalCount(Long memberId) { long count = goalRepository.countByMember_IdAndStatus(memberId, GoalStatus.ACTIVE); return new GoalActiveCountResponseDto((int) count); } + + + // 주요 목표 조회 + public GoalPrimaryListResponseDto getPrimaryGoals(Long memberId) { + var goals = goalRepository.findAllByMemberIdAndStatusOrderByCreatedAtDesc( + memberId, GoalStatus.ACTIVE + ); + return GoalConverter.toPrimaryListResponse(goals); + } } diff --git a/src/main/java/org/umc/valuedi/domain/ledger/entity/LedgerEntry.java b/src/main/java/org/umc/valuedi/domain/ledger/entity/LedgerEntry.java index 3c8592f0..9724958e 100644 --- a/src/main/java/org/umc/valuedi/domain/ledger/entity/LedgerEntry.java +++ b/src/main/java/org/umc/valuedi/domain/ledger/entity/LedgerEntry.java @@ -6,6 +6,7 @@ import org.umc.valuedi.domain.asset.entity.CardApproval; import org.umc.valuedi.domain.ledger.enums.TransactionType; import org.umc.valuedi.domain.member.entity.Member; +import org.umc.valuedi.global.entity.BaseEntity; import java.time.LocalDateTime; @@ -20,7 +21,7 @@ @Index(name = "uq_ledger_entry_bank_transaction_id", columnList = "bank_transaction_id", unique = true), @Index(name = "uq_ledger_entry_card_approval_id", columnList = "card_approval_id", unique = true) }) -public class LedgerEntry { +public class LedgerEntry extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepository.java b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepository.java index 76d63928..50d637f8 100644 --- a/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepository.java +++ b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepository.java @@ -1,9 +1,20 @@ package org.umc.valuedi.domain.ledger.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.umc.valuedi.domain.ledger.entity.LedgerEntry; -public interface LedgerEntryRepository extends JpaRepository { +import java.util.List; +import java.util.Set; + +public interface LedgerEntryRepository extends JpaRepository, LedgerEntryRepositoryCustom { boolean existsByBankTransactionId(Long bankTransactionId); boolean existsByCardApprovalId(Long cardApprovalId); + + @Query("SELECT le.cardApproval.id FROM LedgerEntry le WHERE le.cardApproval.id IN :ids") + Set findExistingCardApprovalIds(@Param("ids") List ids); + + @Query("SELECT le.bankTransaction.id FROM LedgerEntry le WHERE le.bankTransaction.id IN :ids") + Set findExistingBankTransactionIds(@Param("ids") List ids); } diff --git a/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryCustom.java b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryCustom.java new file mode 100644 index 00000000..3a4e5ea5 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryCustom.java @@ -0,0 +1,8 @@ +package org.umc.valuedi.domain.ledger.repository; + +import org.umc.valuedi.domain.ledger.entity.LedgerEntry; +import java.util.List; + +public interface LedgerEntryRepositoryCustom { + void bulkInsert(List entries); +} diff --git a/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryImpl.java b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryImpl.java new file mode 100644 index 00000000..1b07842f --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryImpl.java @@ -0,0 +1,42 @@ +package org.umc.valuedi.domain.ledger.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.umc.valuedi.domain.ledger.entity.LedgerEntry; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class LedgerEntryRepositoryImpl implements LedgerEntryRepositoryCustom { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void bulkInsert(List entries) { + if (entries == null || entries.isEmpty()) { + return; + } + + String sql = "INSERT INTO ledger_entry (member_id, category_id, bank_transaction_id, card_approval_id, transaction_at, transaction_type, title, memo, is_user_modified, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())"; + + jdbcTemplate.batchUpdate(sql, + entries, + 100, + (PreparedStatement ps, LedgerEntry le) -> { + ps.setLong(1, le.getMember().getId()); + ps.setLong(2, le.getCategory().getId()); + ps.setObject(3, le.getBankTransaction() != null ? le.getBankTransaction().getId() : null); + ps.setObject(4, le.getCardApproval() != null ? le.getCardApproval().getId() : null); + ps.setTimestamp(5, Timestamp.valueOf(le.getTransactionAt())); + ps.setString(6, le.getTransactionType().toString()); + ps.setString(7, le.getTitle()); + ps.setString(8, le.getMemo()); + ps.setBoolean(9, le.getIsUserModified()); + }); + } +} \ No newline at end of file diff --git a/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerQueryRepository.java b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerQueryRepository.java index 4a6eaf78..4c031a1c 100644 --- a/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerQueryRepository.java +++ b/src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerQueryRepository.java @@ -248,6 +248,44 @@ private OrderSpecifier getOrderSpecifier(LedgerSortType sort) { return ledgerEntry.transactionAt.desc(); } } + + public Page searchByPeriodLatest( + Long memberId, + LocalDateTime from, + LocalDateTime to, + Pageable pageable + ) { + List content = queryFactory + .selectFrom(ledgerEntry) + .leftJoin(ledgerEntry.category, category).fetchJoin() + .leftJoin(ledgerEntry.bankTransaction, bankTransaction).fetchJoin() + .leftJoin(ledgerEntry.cardApproval, cardApproval).fetchJoin() + .where( + ledgerEntry.member.id.eq(memberId), + periodBetween(from, to) + ) + .orderBy(ledgerEntry.transactionAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(ledgerEntry.count()) + .from(ledgerEntry) + .where( + ledgerEntry.member.id.eq(memberId), + periodBetween(from, to) + ) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + private BooleanExpression periodBetween(LocalDateTime from, LocalDateTime to) { + if (from == null || to == null) return null; + return ledgerEntry.transactionAt.between(from, to); + } + } diff --git a/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java b/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java index 4c35823a..311834ef 100644 --- a/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java +++ b/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java @@ -2,15 +2,14 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.umc.valuedi.domain.asset.entity.BankTransaction; import org.umc.valuedi.domain.asset.entity.CardApproval; import org.umc.valuedi.domain.asset.enums.CancelStatus; import org.umc.valuedi.domain.asset.enums.TransactionDirection; -import org.umc.valuedi.domain.asset.repository.bank.BankTransactionRepository; -import org.umc.valuedi.domain.asset.repository.card.CardApprovalRepository; +import org.umc.valuedi.domain.asset.repository.bank.bankTransaction.BankTransactionRepository; +import org.umc.valuedi.domain.asset.repository.card.cardApproval.CardApprovalRepository; import org.umc.valuedi.domain.ledger.dto.request.LedgerSyncRequest; import org.umc.valuedi.domain.ledger.entity.Category; import org.umc.valuedi.domain.ledger.entity.CategoryKeyword; @@ -27,15 +26,16 @@ import java.time.Duration; import java.time.LocalDate; -import java.util.Collections; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @Service @RequiredArgsConstructor -@Transactional public class LedgerSyncService { private final LedgerEntryRepository ledgerEntryRepository; @@ -65,247 +65,161 @@ public void init() { refreshKeywordCache(); } - // 주기적으로 캐시 갱신이 필요하다면 별도 스케줄러 사용 가능 public void refreshKeywordCache() { List keywords = categoryKeywordRepository.findAllWithCategory(); - Map newCache = keywords.stream() - .collect(Collectors.toMap(CategoryKeyword::getKeyword, CategoryKeyword::getCategory, (existing, replacement) -> existing)); - this.keywordCache = Collections.unmodifiableMap(newCache); + this.keywordCache = keywords.stream().collect(Collectors.toMap(CategoryKeyword::getKeyword, CategoryKeyword::getCategory, (e, r) -> e)); } - public void syncTransactions(Long memberId, LedgerSyncRequest request) { - // 요청 파라미터 검증 - if (ObjectUtils.isEmpty(request.getYearMonth()) && ObjectUtils.isEmpty(request.getFromDate()) || ObjectUtils.isEmpty(request.getToDate())) { - throw new LedgerException(LedgerErrorCode.INVALID_SYNC_REQUEST); - } + @Transactional + public void syncTransactionsAndUpdateMember(Long memberId, LocalDate from, LocalDate to) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new LedgerException(MemberErrorCode.MEMBER_NOT_FOUND)); + syncTransactions(member, from, to); + member.updateLastSyncedAt(); + } + @Transactional + public void updateMemberLastSyncedAt(Long memberId) { Member member = memberRepository.findById(memberId) .orElseThrow(() -> new LedgerException(MemberErrorCode.MEMBER_NOT_FOUND)); + member.updateLastSyncedAt(); + } + + @Transactional + public void syncTransactions(Long memberId, LedgerSyncRequest request) { + Member member = memberRepository.findById(memberId).orElseThrow(() -> new LedgerException(MemberErrorCode.MEMBER_NOT_FOUND)); + LocalDate from = request.getFromDate(); + LocalDate to = request.getToDate(); + syncTransactions(member, from, to); + } + + // 트랜잭션 어노테이션 제거 (상위 메서드에서 관리) + public void syncTransactions(Member member, LocalDate from, LocalDate to) { + if (to.isBefore(from)) { + throw new LedgerException(LedgerErrorCode.INVALID_DATE_RANGE); + } Category defaultCategory = categoryRepository.findByCode("ETC") .orElseThrow(() -> new LedgerException(LedgerErrorCode.CATEGORY_NOT_FOUND)); Category transferCategory = categoryRepository.findByCode("TRANSFER") .orElseThrow(() -> new LedgerException(LedgerErrorCode.CATEGORY_NOT_FOUND)); - // 2. 날짜 범위 설정 로직 개선 - LocalDate from; - LocalDate to; - - if (ObjectUtils.isNotEmpty(request.getYearMonth())) { - // Case 1: yearMonth가 있는 경우 -> 해당 월 전체 (1일 ~ 말일) - from = request.getYearMonth().atDay(1); - to = request.getYearMonth().atEndOfMonth(); - } else { - // Case 2: fromDate가 있는 경우 - from = request.getFromDate(); - if (ObjectUtils.isNotEmpty(request.getToDate())) { - // Case 2-1: toDate도 있는 경우 -> fromDate ~ toDate - to = request.getToDate(); - } else { - // Case 2-2: toDate가 없는 경우 -> fromDate ~ 오늘(현재) - to = LocalDate.now(); - } - } - - // 유효성 검사: 종료일이 시작일보다 앞서면 안됨 - if (to.isBefore(from)) { - throw new LedgerException(LedgerErrorCode.INVALID_DATE_RANGE); // 에러 코드 추가 필요 - } - - // 카드 승인 내역 미리 조회 (매칭 오차 고려 +-1일 버퍼) List cards = cardApprovalRepository.findByUsedDateBetween(from.minusDays(1), to.plusDays(1)); - // 카드 승인 내역 동기화 (조회된 리스트 전달) - syncCardApprovals(member, from, to, defaultCategory); + List allNewEntries = new ArrayList<>(); + syncCardApprovals(member, from, to, defaultCategory, allNewEntries); + syncBankTransactions(member, from, to, cards, defaultCategory, transferCategory, allNewEntries); - // 은행 거래 내역 동기화 (조회된 카드 리스트 전달하여 중복 매칭) - syncBankTransactions(member, from, to, cards, defaultCategory, transferCategory); + if (!allNewEntries.isEmpty()) { + ledgerEntryRepository.bulkInsert(allNewEntries); + } } - private void syncCardApprovals(Member member, LocalDate from, LocalDate to, Category defaultCategory) { + private void syncCardApprovals(Member member, LocalDate from, LocalDate to, Category defaultCategory, List allNewEntries) { List cards = cardApprovalRepository.findByUsedDateBetween(from, to); + if (cards.isEmpty()) return; - for (CardApproval ca : cards) { - if (ledgerEntryRepository.existsByCardApprovalId(ca.getId())) continue; - if (ObjectUtils.isEmpty(ca.getUsedDatetime())) continue; - - String merchantName = ca.getMerchantName(); - String merchantType = ca.getMerchantType(); - - Category category = null; - TransactionType transactionType; + // ID 목록을 추출 + List cardApprovalIds = cards.stream().map(CardApproval::getId).collect(Collectors.toList()); - // 업종(merchantType)으로 먼저 매핑 시도 - if (!ObjectUtils.isEmpty(merchantType)) { - category = mapCategoryByKeyword(merchantType, null); - } + // 이미 존재하는 LedgerEntry의 CardApproval ID를 한 번의 쿼리로 조회 + Set existingIds = ledgerEntryRepository.findExistingCardApprovalIds(cardApprovalIds); - // 업종 매핑 실패 시 가맹점명(merchantName)으로 매핑 시도 - if (ObjectUtils.isEmpty(category) && !ObjectUtils.isEmpty(merchantName)) { - category = mapCategoryByKeyword(merchantName, defaultCategory); - } + for (CardApproval ca : cards) { + // DB 쿼리 대신 메모리의 Set에서 확인 + if (existingIds.contains(ca.getId())) continue; + if (ca.getUsedDatetime() == null) continue; - // 모든 매핑 실패 시 기본 카테고리 사용 - if (ObjectUtils.isEmpty(category)) { - category = defaultCategory; - } + String merchantName = ca.getMerchantName(); + Category category = mapCategoryByKeyword(ca.getMerchantType(), null); + if (category == null) category = mapCategoryByKeyword(merchantName, defaultCategory); + if (category == null) category = defaultCategory; - if (ca.getCancelYn().equals(CancelStatus.NORMAL)) { - transactionType = TransactionType.EXPENSE; - } else { - transactionType = TransactionType.INCOME; - } + TransactionType transactionType = ca.getCancelYn() == CancelStatus.NORMAL ? TransactionType.EXPENSE : TransactionType.INCOME; - LedgerEntry entry = LedgerEntry.builder() - .member(member) - .cardApproval(ca) - .category(category) - .title(ObjectUtils.isEmpty(merchantName) ? "카드 승인" : merchantName) - .transactionAt(ca.getUsedDatetime()) - .transactionType(transactionType) // transactionType 필드 설정 - .build(); - ledgerEntryRepository.save(entry); + allNewEntries.add(LedgerEntry.builder() + .member(member).cardApproval(ca).category(category) + .title(merchantName == null || merchantName.isBlank() ? "카드 승인" : merchantName) + .transactionAt(ca.getUsedDatetime()).transactionType(transactionType).build()); } } - public void syncBankTransactions(Member member, LocalDate from, LocalDate to, List cards, Category defaultCategory, Category transferCategory) { + private void syncBankTransactions(Member member, LocalDate from, LocalDate to, List cards, Category defaultCategory, Category transferCategory, List allNewEntries) { List banks = bankTransactionRepository.findByTrDateBetween(from, to); + if (banks.isEmpty()) return; - for (BankTransaction bt : banks) { - // 이미 동기화된 내역은 스킵 (CardApproval에서 이미 처리했을 가능성 포함) - if (ledgerEntryRepository.existsByBankTransactionId(bt.getId())) continue; - // 필수 값 체크 - if (ObjectUtils.isEmpty(bt.getTrDatetime())) continue; + // ID 목록 추출 + List bankTransactionIds = banks.stream().map(BankTransaction::getId).collect(Collectors.toList()); - // desc 필드 결합 - String combinedDesc = Stream.of(bt.getDesc2(), bt.getDesc3(), bt.getDesc4()) - .filter(s -> !ObjectUtils.isEmpty(s)) - .collect(Collectors.joining(" ")); + // 한 번의 쿼리로 조회 + Set existingIds = ledgerEntryRepository.findExistingBankTransactionIds(bankTransactionIds); - // 중복 제거 로직 변경 : 키워드 포함 시 + 실제 매칭 성공 시에만 스킵 - if (isDuplicateOfCardApproval(bt, combinedDesc, cards)) continue; + for (BankTransaction bt : banks) { + // 메모리에서 확인 + if (existingIds.contains(bt.getId())) continue; + if (bt.getTrDatetime() == null) continue; + String combinedDesc = Stream.of(bt.getDesc2(), bt.getDesc3(), bt.getDesc4()).filter(Objects::nonNull).collect(Collectors.joining(" ")); + if (isDuplicateOfCardApproval(bt, combinedDesc, cards)) continue; Category category; TransactionType transactionType; - String title = ObjectUtils.isEmpty(combinedDesc) ? "은행 거래" : combinedDesc; - if (title.length() > 50) { - title = title.substring(0, 50); - } + String title = combinedDesc.isEmpty() ? "은행 거래" : combinedDesc; + if (title.length() > 50) title = title.substring(0, 50); if (isCardSettlement(combinedDesc)) { category = transferCategory; - transactionType = TransactionType.EXPENSE; // 카드대금 정산은 출금 + transactionType = TransactionType.EXPENSE; } else { category = mapCategoryByKeyword(combinedDesc, defaultCategory); - // BankTransaction의 direction을 따름 - if (bt.getDirection() == TransactionDirection.IN) { - transactionType = TransactionType.INCOME; - } else if (bt.getDirection() == TransactionDirection.OUT) { - transactionType = TransactionType.EXPENSE; - } else { - transactionType = TransactionType.EXPENSE; // 기본값 - } + transactionType = bt.getDirection() == TransactionDirection.IN ? TransactionType.INCOME : TransactionType.EXPENSE; } - LedgerEntry entry = LedgerEntry.builder() - .member(member) - .bankTransaction(bt) - .category(category) - .title(title) - .transactionAt(bt.getTrDatetime()) - .transactionType(transactionType) // transactionType 필드 설정 - .build(); - ledgerEntryRepository.save(entry); + allNewEntries.add(LedgerEntry.builder() + .member(member).bankTransaction(bt).category(category) + .title(title).transactionAt(bt.getTrDatetime()).transactionType(transactionType).build()); } } - // --- 판단 함수 --- private boolean isCardSettlement(String text) { - if (ObjectUtils.isEmpty(text)) return false; - return CARD_SETTLEMENT_KEYWORDS.stream().anyMatch(text::contains); - } - - private boolean isDebitCardDuplicate(String text) { - if (ObjectUtils.isEmpty(text)) return false; - - // 정산/청구는 중복이 아니므로, 먼저 확인하여 제외 - if (isCardSettlement(text)) { - return false; - } - - return CARD_PAYMENT_KEYWORDS.stream().anyMatch(text::contains); + return text != null && CARD_SETTLEMENT_KEYWORDS.stream().anyMatch(text::contains); } private Category mapCategoryByKeyword(String text, Category defaultCategory) { - if (ObjectUtils.isEmpty(text) || text.isEmpty()) return defaultCategory; - - // 캐시된 키워드 맵을 순회하며 포함 여부 확인 - // (키워드가 많아지면 Aho-Corasick 등 알고리즘 최적화 필요, 현재는 단순 루프) + if (text == null || text.isEmpty()) return defaultCategory; for (Map.Entry entry : keywordCache.entrySet()) { - if (text.contains(entry.getKey())) { - return entry.getValue(); - } + if (text.contains(entry.getKey())) return entry.getValue(); } return defaultCategory; } - // 은행 거래가 카드 승인 내역과 중복되는지 정밀하게 판단 private boolean isDuplicateOfCardApproval(BankTransaction bt, String combinedDesc, List cards) { - // 1. 출금(OUT)인 경우에만 체크 - if (bt.getDirection() != TransactionDirection.OUT) return false; - - // 2. 정산/청구 키워드가 있으면 중복 아님 (TRANSFER로 처리됨) - if (isCardSettlement(combinedDesc)) return false; - - // 3. 카드 결제 후보 키워드가 있는 경우에만 매칭 시도 (선택 사항이지만 성능 최적화 위해 적용) - if (!hasCardPaymentKeyword(combinedDesc)) return false; - - // 4. 실제 매칭 로직 (금액, 시간, 상호명 유사도) + if (bt.getDirection() != TransactionDirection.OUT || isCardSettlement(combinedDesc) || !hasCardPaymentKeyword(combinedDesc)) return false; return cards.stream().anyMatch(ca -> isMatch(bt, combinedDesc, ca)); } private boolean isMatch(BankTransaction bt, String bankDesc, CardApproval ca) { - // 1. 금액 일치 여부 (정확히 일치) if (bt.getOutAmount().compareTo(ca.getUsedAmount()) != 0) return false; + if (Duration.between(bt.getTrDatetime(), ca.getUsedDatetime()).abs().toHours() > 6) return false; - // 2. 시간 근접 여부 (±6시간 이내) - long hoursDiff = Duration.between(bt.getTrDatetime(), ca.getUsedDatetime()).abs().toHours(); - if (hoursDiff > 6) return false; - - // 3. 상호/적요 유사도 (정규화 후 포함 여부 확인) String normBankDesc = normalizeText(bankDesc); String normMerchant = normalizeText(ca.getMerchantName()); - // 한쪽이 너무 짧으면 매칭 위험하므로 스킵 (예: 2자 미만) if (normBankDesc.length() < 2 || normMerchant.length() < 2) return false; - // LCS 길이 계산 int lcsLength = getLongestCommonSubstringLength(normBankDesc, normMerchant); - - - // 기준: 짧은 문자열 길이의 60% 이상 겹치거나, 4글자 이상 겹치면 일치로 판단 - int minLength = Math.min(normBankDesc.length(), normMerchant.length()); - return lcsLength >= 4 || (double) lcsLength / minLength >= 0.6; + return lcsLength >= 4 || (double) lcsLength / Math.min(normBankDesc.length(), normMerchant.length()) >= 0.6; } private String normalizeText(String text) { - if (text == null) return ""; - return text.replaceAll("[^a-zA-Z0-9가-힣]", "") // 특수문자, 공백 제거 - .replace("주식회사", "") - .replace("유한회사", "") - .replace("체크", "") // "체크우리" 등에서 제거 - .replace("카드", "") // "카드" 제거 - .toUpperCase(); + return text == null ? "" : text.replaceAll("[^a-zA-Z0-9가-힣]", "").replace("주식회사", "").replace("유한회사", "").replace("체크", "").replace("카드", "").toUpperCase(); } - // LCS (Longest Common Substring) 길이 계산 private int getLongestCommonSubstringLength(String s1, String s2) { int m = s1.length(); int n = s2.length(); int[][] dp = new int[m + 1][n + 1]; int maxLength = 0; - for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (s1.charAt(i - 1) == s2.charAt(j - 1)) { @@ -319,11 +233,7 @@ private int getLongestCommonSubstringLength(String s1, String s2) { return maxLength; } - private boolean hasCardPaymentKeyword(String text) { - if (ObjectUtils.isEmpty(text)) return false; - return CARD_PAYMENT_KEYWORDS.stream().anyMatch(text::contains); + return text != null && CARD_PAYMENT_KEYWORDS.stream().anyMatch(text::contains); } - - } diff --git a/src/main/java/org/umc/valuedi/domain/mbti/entity/MbtiTypeInfo.java b/src/main/java/org/umc/valuedi/domain/mbti/entity/MbtiTypeInfo.java index 27ea7ad4..bd68bf93 100644 --- a/src/main/java/org/umc/valuedi/domain/mbti/entity/MbtiTypeInfo.java +++ b/src/main/java/org/umc/valuedi/domain/mbti/entity/MbtiTypeInfo.java @@ -30,7 +30,7 @@ public class MbtiTypeInfo { private String tagline; @Lob - @Column(name = "detail", nullable = false) + @Column(name = "detail", nullable = false, columnDefinition = "TEXT") private String detail; @Column(name = "warning", nullable = false, length = 500) diff --git a/src/main/java/org/umc/valuedi/domain/member/entity/Member.java b/src/main/java/org/umc/valuedi/domain/member/entity/Member.java index 1eac49be..96043fbd 100644 --- a/src/main/java/org/umc/valuedi/domain/member/entity/Member.java +++ b/src/main/java/org/umc/valuedi/domain/member/entity/Member.java @@ -76,6 +76,9 @@ public class Member extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + @Column(name = "last_synced_at") + private LocalDateTime lastSyncedAt; + @Builder.Default @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private List memberTermsList = new ArrayList<>(); @@ -102,4 +105,8 @@ public void withdraw(WithdrawalReason reason) { this.passwordHash = null; this.withdrawalReason = reason; } + + public void updateLastSyncedAt() { + this.lastSyncedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java b/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java index 15c4f9c4..0fb4480d 100644 --- a/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java +++ b/src/main/java/org/umc/valuedi/global/config/AsyncConfig.java @@ -23,13 +23,13 @@ public Executor mailExecutor() { return executor; } - @Bean(name = "assetSyncExecutor") - public Executor assetSyncExecutor() { + @Bean(name = "assetFetchExecutor") + public Executor assetFetchExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); // 동시에 처리할 연동 작업 수 - executor.setMaxPoolSize(10); - executor.setQueueCapacity(20); - executor.setThreadNamePrefix("AssetSync-"); + executor.setCorePoolSize(5); // 기본 스레드 수 - 동시에 처리할 작업 수 (CPU 코어 수에 맞게 조절) + executor.setMaxPoolSize(10); // 최대 스레드 수 + executor.setQueueCapacity(100); // 큐 용량 + executor.setThreadNamePrefix("AssetFetch-"); executor.initialize(); return executor; } diff --git a/src/main/java/org/umc/valuedi/global/external/codef/config/CodefFeignConfig.java b/src/main/java/org/umc/valuedi/global/external/codef/config/CodefFeignConfig.java index d765f19b..ac89e614 100644 --- a/src/main/java/org/umc/valuedi/global/external/codef/config/CodefFeignConfig.java +++ b/src/main/java/org/umc/valuedi/global/external/codef/config/CodefFeignConfig.java @@ -4,8 +4,10 @@ import feign.RequestInterceptor; import feign.codec.Decoder; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.umc.valuedi.global.external.codef.service.CodefTokenService; +@Configuration public class CodefFeignConfig { @Bean diff --git a/src/main/java/org/umc/valuedi/global/external/codef/converter/CodefAssetConverter.java b/src/main/java/org/umc/valuedi/global/external/codef/converter/CodefAssetConverter.java index 245e9fbe..11f2714c 100644 --- a/src/main/java/org/umc/valuedi/global/external/codef/converter/CodefAssetConverter.java +++ b/src/main/java/org/umc/valuedi/global/external/codef/converter/CodefAssetConverter.java @@ -21,6 +21,7 @@ import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; @@ -125,19 +126,31 @@ private Card toCard(CodefAssetResDTO.Card item, CodefConnection connection) { } } - public List toCardApprovalList(List data) { + public List toCardApprovalList(List data, List cardList) { return data.stream() - .map(this::toCardApproval) + .map(item -> toCardApproval(item, cardList)) .filter(Objects::nonNull) .collect(Collectors.toList()); } - private CardApproval toCardApproval(CodefAssetResDTO.CardApproval item) { + private CardApproval toCardApproval(CodefAssetResDTO.CardApproval item, List cardList) { try { LocalDate usedDate = parseDate(item.getResUsedDate()); LocalTime usedTime = parseTime(item.getResUsedTime()); + Card card = findMatchingCard(item.getResCardNo(), cardList); + if (card == null) { + log.warn("승인 내역에 해당하는 카드를 찾을 수 없습니다. 카드번호: {}", item.getResCardNo()); + return null; + } + + String merchantType = item.getResMemberStoreType(); + if (merchantType == null || merchantType.isBlank()) { + merchantType = "기타"; // 기본값 설정 + } + return CardApproval.builder() + .card(card) .usedDate(usedDate) .usedTime(usedTime) .usedDatetime(LocalDateTime.of(usedDate, usedTime)) @@ -151,7 +164,7 @@ private CardApproval toCardApproval(CodefAssetResDTO.CardApproval item) { .cancelAmount(parseAmount(item.getResCancelAmount())) .merchantCorpNo(item.getResMemberStoreCorpNo()) .merchantName(item.getResMemberStoreName()) - .merchantType(item.getResMemberStoreType()) + .merchantType(merchantType) // 기본값이 설정된 변수 사용 .merchantNo(item.getResMemberStoreNo()) .commStartDate(parseDate(item.getCommStartDate()).atStartOfDay()) .commEndDate(parseDate(item.getCommEndDate()).atStartOfDay()) @@ -164,6 +177,38 @@ private CardApproval toCardApproval(CodefAssetResDTO.CardApproval item) { } } + private Card findMatchingCard(String approvalCardNo, List cardList) { + if (approvalCardNo == null) { + return null; + } + + // 숫자만 추출 + String cleanApprovalNo = approvalCardNo.replaceAll("[^0-9]", ""); + if (cleanApprovalNo.length() < 8) { + // 숫자가 너무 적으면 매칭 불가 (최소 앞4, 뒤4 필요) + return null; + } + + String approvalPrefix = cleanApprovalNo.substring(0, 4); + String approvalSuffix = cleanApprovalNo.substring(cleanApprovalNo.length() - 4); + + return cardList.stream() + .filter(c -> { + String cardNo = c.getCardNoMasked(); + if (cardNo == null) return false; + + String cleanCardNo = cardNo.replaceAll("[^0-9]", ""); + if (cleanCardNo.length() < 8) return false; + + String cardPrefix = cleanCardNo.substring(0, 4); + String cardSuffix = cleanCardNo.substring(cleanCardNo.length() - 4); + + return approvalPrefix.equals(cardPrefix) && approvalSuffix.equals(cardSuffix); + }) + .findFirst() + .orElse(null); + } + private Long parseAmount(String amount) { if (amount == null || amount.isEmpty()) return 0L; try { diff --git a/src/main/java/org/umc/valuedi/global/external/codef/service/CodefAssetService.java b/src/main/java/org/umc/valuedi/global/external/codef/service/CodefAssetService.java index b5b5f61c..bf750544 100644 --- a/src/main/java/org/umc/valuedi/global/external/codef/service/CodefAssetService.java +++ b/src/main/java/org/umc/valuedi/global/external/codef/service/CodefAssetService.java @@ -24,6 +24,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; @Slf4j @Service @@ -57,15 +59,21 @@ public List getBankAccounts(CodefConnection connection) { return codefAssetConverter.toBankAccountList(allAccounts, connection); } + // 기존 메서드 (3개월 전부터 현재까지) public List getBankTransactions(CodefConnection connection, BankAccount account) { + LocalDate now = LocalDate.now(); + return getBankTransactions(connection, account, now.minusMonths(3), now); + } + + // 오버로딩된 메서드 (시작일, 종료일 지정) + public List getBankTransactions(CodefConnection connection, BankAccount account, LocalDate startDate, LocalDate endDate) { Map requestBody = createAssetRequestBody(connection); String originalAccountNo = encryptUtil.decryptAES(account.getAccountNoEnc()); requestBody.put("account", originalAccountNo); - LocalDate now = LocalDate.now(); - requestBody.put("startDate", now.minusMonths(3).format(DateTimeFormatter.BASIC_ISO_DATE)); - requestBody.put("endDate", now.format(DateTimeFormatter.BASIC_ISO_DATE)); + requestBody.put("startDate", startDate.format(DateTimeFormatter.BASIC_ISO_DATE)); + requestBody.put("endDate", endDate.format(DateTimeFormatter.BASIC_ISO_DATE)); requestBody.put("orderBy", "0"); requestBody.put("inquiryType", "1"); @@ -119,14 +127,26 @@ public List getCards(CodefConnection connection) { return codefAssetConverter.toCardList(cardList, connection); } + // 기존 메서드 (3개월 전부터 현재까지) public List getCardApprovals(CodefConnection connection) { + LocalDate now = LocalDate.now(); + return getCardApprovals(connection, now.minusMonths(3), now); + } + + // 오버로딩된 메서드 (시작일, 종료일 지정) + public List getCardApprovals(CodefConnection connection, LocalDate startDate, LocalDate endDate) { + // 이 메서드는 이제 connection 내부의 cardList를 사용하므로, 외부에서 cardList가 채워져 있어야 함. + return getCardApprovals(connection, connection.getCardList(), startDate, endDate); + } + + // AssetFetchWorker가 사용할 새로운 오버로딩 메서드 + public List getCardApprovals(CodefConnection connection, List cards, LocalDate startDate, LocalDate endDate) { Map requestBody = new HashMap<>(); requestBody.put("connectedId", connection.getConnectedId()); requestBody.put("organization", connection.getOrganization()); - LocalDate now = LocalDate.now(); - requestBody.put("startDate", now.minusMonths(3).format(DateTimeFormatter.BASIC_ISO_DATE)); - requestBody.put("endDate", now.format(DateTimeFormatter.BASIC_ISO_DATE)); + requestBody.put("startDate", startDate.format(DateTimeFormatter.BASIC_ISO_DATE)); + requestBody.put("endDate", endDate.format(DateTimeFormatter.BASIC_ISO_DATE)); requestBody.put("orderBy", "0"); requestBody.put("inquiryType", "1"); requestBody.put("memberStoreInfoType", "1"); // 가맹점 상세 정보 조회 옵션 @@ -145,7 +165,8 @@ public List getCardApprovals(CodefConnection connection) { return List.of(); } - return codefAssetConverter.toCardApprovalList(approvalList); + // 승인 내역을 카드에 매핑하기 위해 명시적으로 전달받은 카드 목록을 사용 + return codefAssetConverter.toCardApprovalList(approvalList, cards); } private Map createAssetRequestBody(CodefConnection connection) { diff --git a/stop b/stop deleted file mode 100644 index b833c99c..00000000 --- a/stop +++ /dev/null @@ -1 +0,0 @@ -MySQL80