From d0ebd485d18ba161ec3858339b65d86f2b1c07a3 Mon Sep 17 00:00:00 2001 From: dohee <152317074+seamooll@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:21:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[REFACTOR/#64]=20=EC=9D=80=ED=96=89=20?= =?UTF-8?q?=EA=B3=84=EC=A2=8C=20=EC=A1=B0=ED=9A=8C=20API=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=A0=95=EB=B3=B4=20=ED=86=B5=ED=95=A9=20=EB=B0=8F?= =?UTF-8?q?=20QueryDSL=20=EC=A0=84=ED=99=98=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐Ÿ”— Related Issue - Closes #64 ## ๐Ÿ“ Summary - ์€ํ–‰๋ณ„ ๋ชฉํ‘œ ์กฐํšŒ๋ฅผ ๋ณ„๋„ API๋กœ ๋ถ„๋ฆฌํ•˜์ง€ ์•Š๊ณ , ๊ณ„์ขŒ ์กฐํšŒ API์— ์—ฐ๊ฒฐ๋œ ๋ชฉํ‘œ ์ •๋ณด๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•˜๋„๋ก ์ˆ˜์ •ํ•˜์˜€์Šต๋‹ˆ๋‹ค. - JPQL ๊ธฐ๋ฐ˜ ์กฐํšŒ ๋กœ์ง์„ QueryDSL๋กœ ๋ฆฌํŒฉํ† ๋งํ•˜์˜€์Šต๋‹ˆ๋‹ค. ## ๐Ÿ”„ Changes - [X] API ๋ณ€๊ฒฝ (์ถ”๊ฐ€/์ˆ˜์ •) - [ ] ๋ฐ์ดํ„ฐ ๋ฐ ๋„๋ฉ”์ธ ๋ณ€๊ฒฝ (DB, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง) - [ ] ์„ค์ • ๋˜๋Š” ์ธํ”„๋ผ ๊ด€๋ จ ๋ณ€๊ฒฝ - [X] ๋ฆฌํŒฉํ† ๋ง ## ๐Ÿ’ฌ Questions & Review Points ## ๐Ÿ“ธ API Test Results (Swagger) image image ## โœ… Checklist - [x] API ํ…Œ์ŠคํŠธ ์™„๋ฃŒ - [x] ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์‚ฌ์ง„ ์ฒจ๋ถ€ - [x] ๋นŒ๋“œ ์„ฑ๊ณต ํ™•์ธ (./gradlew build) --- .../asset/controller/AssetController.java | 6 +- .../asset/controller/AssetControllerDocs.java | 102 +++++++++++++----- .../asset/converter/AssetConverter.java | 55 +++++++++- .../domain/asset/dto/res/BankResDTO.java | 74 +++++++++++++ .../domain/asset/entity/BankAccount.java | 4 + .../bank/BankAccountRepository.java | 42 -------- .../bankAccount/BankAccountRepository.java | 7 ++ .../BankAccountRepositoryCustom.java | 16 +++ .../BankAccountRepositoryImpl.java | 59 ++++++++++ .../BankTransactionRepository.java | 2 +- .../BankTransactionRepositoryCustom.java | 2 +- .../BankTransactionRepositoryImpl.java | 2 +- .../card/{ => card}/CardRepository.java | 2 +- .../CardApprovalRepository.java | 2 +- .../CardApprovalRepositoryCustom.java | 2 +- .../CardApprovalRepositoryImpl.java | 2 +- .../{ => command}/AssetSyncService.java | 10 +- .../{ => query}/AssetQueryService.java | 12 +-- .../service/ConnectionEventListener.java | 2 +- .../domain/goal/converter/GoalConverter.java | 14 ++- .../umc/valuedi/domain/goal/entity/Goal.java | 7 +- .../goal/repository/GoalRepository.java | 2 +- .../goal/repository/GoalRepositoryCustom.java | 9 ++ .../goal/repository/GoalRepositoryImpl.java | 29 +++++ .../goal/service/query/GoalQueryService.java | 6 +- .../service/command/LedgerSyncService.java | 4 +- 26 files changed, 374 insertions(+), 100 deletions(-) delete mode 100644 src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankAccountRepository.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepository.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryCustom.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryImpl.java rename src/main/java/org/umc/valuedi/domain/asset/repository/bank/{ => bankTransaction}/BankTransactionRepository.java (84%) rename src/main/java/org/umc/valuedi/domain/asset/repository/bank/{ => bankTransaction}/BankTransactionRepositoryCustom.java (73%) rename src/main/java/org/umc/valuedi/domain/asset/repository/bank/{ => bankTransaction}/BankTransactionRepositoryImpl.java (97%) rename src/main/java/org/umc/valuedi/domain/asset/repository/card/{ => card}/CardRepository.java (96%) rename src/main/java/org/umc/valuedi/domain/asset/repository/card/{ => cardApproval}/CardApprovalRepository.java (88%) rename src/main/java/org/umc/valuedi/domain/asset/repository/card/{ => cardApproval}/CardApprovalRepositoryCustom.java (73%) rename src/main/java/org/umc/valuedi/domain/asset/repository/card/{ => cardApproval}/CardApprovalRepositoryImpl.java (98%) rename src/main/java/org/umc/valuedi/domain/asset/service/{ => command}/AssetSyncService.java (95%) rename src/main/java/org/umc/valuedi/domain/asset/service/{ => query}/AssetQueryService.java (82%) create mode 100644 src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryCustom.java create mode 100644 src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryImpl.java 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..3275f628 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 @@ -9,7 +9,7 @@ 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.connection.service.ConnectionQueryService; import org.umc.valuedi.global.apiPayload.ApiResponse; import org.umc.valuedi.global.apiPayload.code.GeneralSuccessCode; @@ -65,8 +65,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 ) { 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..b76f3c05 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 ); 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/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/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/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..35ad42d3 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryCustom.java @@ -0,0 +1,16 @@ +package org.umc.valuedi.domain.asset.repository.bank.bankAccount; + +import org.umc.valuedi.domain.asset.entity.BankAccount; + +import java.util.List; + +public interface BankAccountRepositoryCustom { + // ํŠน์ • ์€ํ–‰๋ณ„ ํ™œ์„ฑ ๊ณ„์ขŒ ๋ชฉ๋ก ์กฐํšŒ + List findAllByMemberIdAndOrganization(Long memberId, String organization); + + // ์ „์ฒด ํ™œ์„ฑ ๊ณ„์ขŒ ๋ชฉ๋ก ์กฐํšŒ (์ตœ์‹ ์ˆœ) + List findAllByMemberId(Long memberId); + + // ์ด ํ™œ์„ฑ ๊ณ„์ขŒ ์ˆ˜ ์นด์šดํŠธ + long countByMemberId(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..51cd559d --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankAccount/BankAccountRepositoryImpl.java @@ -0,0 +1,59 @@ +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 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; + } +} 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/bankTransaction/BankTransactionRepository.java similarity index 84% rename from src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepository.java rename to src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepository.java index 7ac97e80..02d0f3cb 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/BankTransactionRepository.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepository.java @@ -1,4 +1,4 @@ -package org.umc.valuedi.domain.asset.repository.bank; +package org.umc.valuedi.domain.asset.repository.bank.bankTransaction; import org.springframework.data.jpa.repository.JpaRepository; import org.umc.valuedi.domain.asset.entity.BankTransaction; 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 97% 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..f73d09bf 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,4 +1,4 @@ -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; 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 96% 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..fcd3d092 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; 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/cardApproval/CardApprovalRepository.java similarity index 88% rename from src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepository.java rename to src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepository.java index 6fe497b4..be58af96 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/card/CardApprovalRepository.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepository.java @@ -1,4 +1,4 @@ -package org.umc.valuedi.domain.asset.repository.card; +package org.umc.valuedi.domain.asset.repository.card.cardApproval; import org.springframework.data.jpa.repository.JpaRepository; import org.umc.valuedi.domain.asset.entity.CardApproval; 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 98% 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..18c68887 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,4 +1,4 @@ -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; 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 95% 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..bb847003 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,4 +1,4 @@ -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; @@ -12,10 +12,10 @@ 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; 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/ConnectionEventListener.java b/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionEventListener.java index 82032a02..e21d2f08 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 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..d37e9933 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 @@ -19,7 +19,7 @@ private GoalConverter() {} public static Goal toEntity(Member member, GoalCreateRequestDto req) { return Goal.builder() .member(member) - .accountId(null) // ๊ณ„์ขŒ ์—ฐ๋™ ๋˜๋ฉด ์ฑ„์šฐ๊ธฐ + .bankAccount(null) // ๊ณ„์ขŒ ์—ฐ๋™ ๋˜๋ฉด ์ฑ„์šฐ๊ธฐ .title(req.title()) .startDate(req.startDate()) .endDate(req.endDate()) @@ -66,7 +66,7 @@ public static GoalListResponseDto.GoalSummaryDto toSummaryDto( remainingAmount, remainingDays, achievementRate, - null, // ๊ณ„์ขŒ ์—ฐ๋™ ๋˜๋ฉด ์ฑ„์šฐ๊ธฐ + goal.getBankAccount() != null ? goal.getBankAccount().getAccountName() : null, goal.getStatus(), goal.getColor(), goal.getIcon() @@ -80,6 +80,14 @@ public static GoalDetailResponseDto toDetailDto( ) { long remainingDays = calcRemainingDays(goal.getEndDate()); + GoalDetailResponseDto.AccountDto accountDto = null; + if (goal.getBankAccount() != null) { + accountDto = new GoalDetailResponseDto.AccountDto( + goal.getBankAccount().getCodefConnection().getOrganization(), // ์€ํ–‰๋ช…(๊ธฐ๊ด€๋ช…) + goal.getBankAccount().getAccountDisplay() // ๊ณ„์ขŒ๋ฒˆํ˜ธ + ); + } + return new GoalDetailResponseDto( goal.getId(), goal.getTitle(), @@ -87,7 +95,7 @@ public static GoalDetailResponseDto toDetailDto( goal.getTargetAmount(), remainingDays, achievementRate, - null, // ๊ณ„์ขŒ ์—ฐ๋™ ๋˜๋ฉด ์ฑ„์šฐ๊ธฐ + accountDto, goal.getStatus(), goal.getColor(), goal.getIcon() 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..916a2841 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; @@ -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") + private BankAccount bankAccount; @Column(name = "title", nullable = false, length = 20) private String title; @@ -97,4 +99,5 @@ public void Fail() { this.completedAt = LocalDateTime.now(); } + public void setBankAccount(BankAccount bankAccount) { this.bankAccount = bankAccount;} } 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..6685ced0 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 @@ -8,7 +8,7 @@ import java.time.LocalDate; import java.util.List; -public interface GoalRepository extends JpaRepository { +public interface GoalRepository extends JpaRepository, GoalRepositoryCustom { List findAllByMember_Id( Long memberId); 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..0634b2b5 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryCustom.java @@ -0,0 +1,9 @@ +package org.umc.valuedi.domain.goal.repository; + +import org.umc.valuedi.domain.goal.entity.Goal; + +import java.util.Optional; + +public interface GoalRepositoryCustom { + Optional findByIdWithDetails(Long goalId); +} 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..b5faa2cf --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryImpl.java @@ -0,0 +1,29 @@ +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 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); + } +} 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..64991c18 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 @@ -27,10 +27,14 @@ public class GoalQueryService { // ๋ชฉํ‘œ ์ƒ์„ธ ์กฐํšŒ public GoalDetailResponseDto getGoalDetail(Long goalId) { - Goal goal = goalRepository.findById(goalId) + Goal goal = goalRepository.findByIdWithDetails(goalId) .orElseThrow(() -> new GoalException(GoalErrorCode.GOAL_NOT_FOUND)); long savedAmount = 0; // ๊ณ„์ขŒ ์—ฐ๋™ ํ›„ ์ˆ˜์ • + if (goal.getBankAccount() != null && goal.getBankAccount().getBalanceAmount() != null) { + savedAmount = goal.getBankAccount().getBalanceAmount(); + } + int rate = achievementRateService.calculateRate(savedAmount, goal.getTargetAmount()); return GoalConverter.toDetailDto(goal, savedAmount, rate); 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..de790c2a 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 @@ -9,8 +9,8 @@ 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; From e5636415ef4267cd818928019fea99d161cebcb8 Mon Sep 17 00:00:00 2001 From: dohee <152317074+seamooll@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:15:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[FEATURE/#68]=20=EC=9D=80=ED=96=89=20?= =?UTF-8?q?=EA=B8=88=EC=9C=B5=EC=82=AC=20=EC=97=B0=EB=8F=99=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=97=B0=EA=B2=B0=EB=90=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐Ÿ”— Related Issue - Closes #68 ## ๐Ÿ“ Summary ์€ํ–‰ ๊ธˆ์œต์‚ฌ ์—ฐ๋™ ์‚ญ์ œ ์‹œ ๊ณ„์ขŒ์™€ ์—ฐ๊ฒฐ๋œ ๋ชฉํ‘œ๋„ ์‚ญ์ œ๋˜๋„๋ก ๋ณด์™„ํ–ˆ์Šต๋‹ˆ๋‹ค. ## ๐Ÿ”„ Changes - [X] API ๋ณ€๊ฒฝ (์ถ”๊ฐ€/์ˆ˜์ •) - [ ] ๋ฐ์ดํ„ฐ ๋ฐ ๋„๋ฉ”์ธ ๋ณ€๊ฒฝ (DB, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง) - [ ] ์„ค์ • ๋˜๋Š” ์ธํ”„๋ผ ๊ด€๋ จ ๋ณ€๊ฒฝ - [ ] ๋ฆฌํŒฉํ† ๋ง ## ๐Ÿ’ฌ Questions & Review Points ## ๐Ÿ“ธ API Test Results (Swagger) ## โœ… Checklist - [x] API ํ…Œ์ŠคํŠธ ์™„๋ฃŒ - [ ] ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์‚ฌ์ง„ ์ฒจ๋ถ€ - [x] ๋นŒ๋“œ ์„ฑ๊ณต ํ™•์ธ (./gradlew build) --- .../connection/service/ConnectionCommandService.java | 11 ++++++++--- .../org/umc/valuedi/domain/goal/entity/Goal.java | 1 - .../domain/goal/repository/GoalRepository.java | 12 ++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) 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/goal/entity/Goal.java b/src/main/java/org/umc/valuedi/domain/goal/entity/Goal.java index 916a2841..ffb5b10f 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 @@ -99,5 +99,4 @@ public void Fail() { this.completedAt = LocalDateTime.now(); } - public void setBankAccount(BankAccount bankAccount) { this.bankAccount = bankAccount;} } 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 6685ced0..5ccd72b4 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,6 +1,9 @@ package org.umc.valuedi.domain.goal.repository; 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; @@ -21,4 +24,13 @@ public interface GoalRepository extends JpaRepository, GoalRepositor List findAllByMember_IdAndStatus(Long memberId, GoalStatus status, Pageable pageable); List findAllByMember_IdAndStatusIn(Long memberId, List statuses, Pageable pageable); + List findAllByBankAccountId(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); } From fc7ee8653de9f7a868dfeb364cdc03fbb8c7c9f3 Mon Sep 17 00:00:00 2001 From: Lee GaEun <137785327+kkeunii@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:35:18 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[FEATURE/#56]=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EB=8B=B9=20=EA=B3=84=EC=A2=8C=20=EC=97=B0=EA=B2=B0=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐Ÿ”— Related Issue - Closes #56 ## ๐Ÿ“ Summary ### Goal ์ƒ์„ฑ ์‹œ ๊ณ„์ขŒ ์—ฐ๋™ ๋ฐ ๊ณ„์ขŒ ๋ณ€๊ฒฝ API ์ถ”๊ฐ€ - ๋ชฉํ‘œ(Goal) ์ƒ์„ฑ ์‹œ ๊ณ„์ขŒ(account) ํ•„์ˆ˜ ์—ฐ๋™ ๊ตฌ์กฐ๋กœ ๋ณ€๊ฒฝ - Goal โ†” BankAccount 1:1 ๊ด€๊ณ„ ์ ์šฉ - ํ˜„์žฌ ๊ณ„์ขŒ๋“ค ์ค‘ ์—ฐ๊ฒฐ ๋œ ๋ชฉํ‘œ๊ฐ€ ์—†๋Š” ๊ณ„์ขŒ ์กฐํšŒํ•˜๋Š” API ์ถ”๊ฐ€ - ๋ชฉํ‘œ์— ์—ฐ๊ฒฐ๋œ ๊ณ„์ขŒ๋ฅผ PUT ๋ฐฉ์‹์œผ๋กœ ์„ค์ •/๊ต์ฒดํ•  ์ˆ˜ ์žˆ๋Š” API ์ถ”๊ฐ€ - Goal ์ƒ์„ธ/์ƒ์„ฑ ์‘๋‹ต์— ์—ฐ๊ฒฐ๋œ ๊ณ„์ขŒ ์ •๋ณด ํฌํ•จ ### Primary ๋ชฉํ‘œ ์กฐํšŒ API ์ถ”๊ฐ€ - ํ™ˆํ™”๋ฉด์—์„œ ์ง„ํ–‰ ์ค‘(ACTIVE) ๋ชฉํ‘œ๋ฅผ ์ตœ์‹ ์ˆœ์œผ๋กœ ์ „์ฒด ์กฐํšŒํ•˜๋Š” /api/goals/primary API ์ถ”๊ฐ€ ## ๐Ÿ”„ Changes - [x] API ๋ณ€๊ฒฝ (์ถ”๊ฐ€/์ˆ˜์ •) - [ ] ๋ฐ์ดํ„ฐ ๋ฐ ๋„๋ฉ”์ธ ๋ณ€๊ฒฝ (DB, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง) - [ ] ์„ค์ • ๋˜๋Š” ์ธํ”„๋ผ ๊ด€๋ จ ๋ณ€๊ฒฝ - [ ] ๋ฆฌํŒฉํ† ๋ง ## ๐Ÿ’ฌ Questions & Review Points ์ผ๋‹จ ์•„์ง ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๋ถˆ๋Ÿฌ์˜ค๋Š” api ์ œ์™ธ ๊ณ„์ขŒ ์—ฐ๋™ ๊ด€๋ จ api ๋ชจ๋‘ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ณ„์ขŒ๋Š” ๋ชฉํ‘œ ์ƒ์„ฑ ์‹œ์— ํ•„์ˆ˜๋กœ id๋ฅผ ์ž…๋ ฅํ•˜๊ฒŒ ๋ณ€๊ฒฝํ–ˆ๊ณ , ๊ณ„์ขŒ ์—ฐ๋™์ด ํ•ด์ง€๋  ๋•Œ ๋‹ค๋ฅธ ๊ณ„์ขŒ๋กœ ๊ณ„์ขŒ๋งŒ ์žฌ์—ฐ๊ฒฐ ํ•ด์•ผํ•˜๋Š”๊ฑธ ๋Œ€๋น„ ํ•ด /api/goals/{goalId}/linked-accounts ๋ฅผ put์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค! ## ๐Ÿ“ธ API Test Results (Swagger) ### ๋ชฉํ‘œ ์ƒ์„ฑ ์‹œ ์„ ํƒํ•œ ๊ณ„์ขŒ๊ฐ€ ์ด๋ฏธ ๋ชฉํ‘œ๋ž‘ ์—ฐ๋™ ๋œ ๊ณ„์ขŒ์ผ ๋•Œ image ### ๋ชฉํ‘œ ์ƒ์„ฑ ์‹œ image ### /api/goals/accounts -> ๋ชฉํ‘œ์— ์—ฐ๊ฒฐ๋˜์ง€ ์•Š์€ ๊ณ„์ขŒ ๋ชฉ๋ก ์กฐํšŒ image ### /api/goals/{goalId}/ledgers -> ๋ชฉํ‘œ ๋ณ„ ๊ฑฐ๋ž˜๋‚ด์—ญ ์กฐํšŒ image ### /api/goals/primary -> ํ™ˆํ™”๋ฉด ๋ชฉํ‘œ ๋ชฉ๋ก ์กฐํšŒ image ## โœ… Checklist - [x] API ํ…Œ์ŠคํŠธ ์™„๋ฃŒ - [x] ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์‚ฌ์ง„ ์ฒจ๋ถ€ - [x] ๋นŒ๋“œ ์„ฑ๊ณต ํ™•์ธ (./gradlew build) --------- Co-authored-by: dohee <152317074+seamooll@users.noreply.github.com> --- .../BankAccountRepositoryCustom.java | 7 + .../BankAccountRepositoryImpl.java | 39 +++++ .../goal/controller/GoalController.java | 82 +++++++++-- .../goal/controller/GoalControllerDocs.java | 133 ++++++++++++++---- .../goal/converter/GoalAccountConverter.java | 21 +++ .../domain/goal/converter/GoalConverter.java | 79 ++++++++--- .../dto/request/GoalCreateRequestDto.java | 9 +- .../request/GoalLinkAccountRequestDto.java | 12 ++ .../dto/request/GoalUpdateRequestDto.java | 2 +- .../goal/dto/response/GoalAccountResDto.java | 29 ++++ .../dto/response/GoalCreateResponseDto.java | 46 +++++- .../dto/response/GoalDetailResponseDto.java | 28 ++++ .../dto/response/GoalListResponseDto.java | 26 +++- .../response/GoalPrimaryListResponseDto.java | 30 ++++ .../umc/valuedi/domain/goal/entity/Goal.java | 16 ++- .../goal/exception/code/GoalErrorCode.java | 9 +- .../goal/exception/code/GoalSuccessCode.java | 5 +- .../goal/repository/GoalRepository.java | 9 +- .../goal/repository/GoalRepositoryCustom.java | 8 ++ .../goal/repository/GoalRepositoryImpl.java | 39 +++++ .../command/GoalAccountCommandService.java | 47 +++++++ .../service/command/GoalCommandService.java | 34 +++-- .../query/GoalAccountQueryService.java | 32 +++++ .../service/query/GoalLedgerQueryService.java | 54 +++++++ .../service/query/GoalListQueryService.java | 19 ++- .../goal/service/query/GoalQueryService.java | 25 +++- .../repository/LedgerQueryRepository.java | 38 +++++ stop | 1 - 28 files changed, 783 insertions(+), 96 deletions(-) create mode 100644 src/main/java/org/umc/valuedi/domain/goal/converter/GoalAccountConverter.java create mode 100644 src/main/java/org/umc/valuedi/domain/goal/dto/request/GoalLinkAccountRequestDto.java create mode 100644 src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalAccountResDto.java create mode 100644 src/main/java/org/umc/valuedi/domain/goal/dto/response/GoalPrimaryListResponseDto.java create mode 100644 src/main/java/org/umc/valuedi/domain/goal/service/command/GoalAccountCommandService.java create mode 100644 src/main/java/org/umc/valuedi/domain/goal/service/query/GoalAccountQueryService.java create mode 100644 src/main/java/org/umc/valuedi/domain/goal/service/query/GoalLedgerQueryService.java delete mode 100644 stop 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 index 35ad42d3..d5a699be 100644 --- 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 @@ -3,6 +3,7 @@ import org.umc.valuedi.domain.asset.entity.BankAccount; import java.util.List; +import java.util.Optional; public interface BankAccountRepositoryCustom { // ํŠน์ • ์€ํ–‰๋ณ„ ํ™œ์„ฑ ๊ณ„์ขŒ ๋ชฉ๋ก ์กฐํšŒ @@ -13,4 +14,10 @@ public interface BankAccountRepositoryCustom { // ์ด ํ™œ์„ฑ ๊ณ„์ขŒ ์ˆ˜ ์นด์šดํŠธ 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 index 51cd559d..9e57b2e8 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -56,4 +57,42 @@ public long countByMemberId(Long memberId) { .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/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 d37e9933..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) - .bankAccount(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,40 +55,68 @@ 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, - goal.getBankAccount() != null ? goal.getBankAccount().getAccountName() : 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; - if (goal.getBankAccount() != null) { - accountDto = new GoalDetailResponseDto.AccountDto( - goal.getBankAccount().getCodefConnection().getOrganization(), // ์€ํ–‰๋ช…(๊ธฐ๊ด€๋ช…) - goal.getBankAccount().getAccountDisplay() // ๊ณ„์ขŒ๋ฒˆํ˜ธ - ); + var info = extractAccountInfo(goal.getBankAccount()); + if (info != null) { + accountDto = new GoalDetailResponseDto.AccountDto(info.bankName(), info.accountNumber()); } return new GoalDetailResponseDto( @@ -107,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 ffb5b10f..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 @@ -19,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 @@ -31,7 +31,7 @@ public class Goal extends BaseEntity { private Member member; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "bank_account_id") + @JoinColumn(name = "bank_account_id", unique = true) private BankAccount bankAccount; @Column(name = "title", nullable = false, length = 20) @@ -46,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; @@ -77,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; } @@ -99,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 5ccd72b4..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,30 +1,29 @@ 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, GoalRepositoryCustom { - List findAllByMember_Id( Long memberId); - + 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); - List findAllByBankAccountId(Long bankAccountId); + boolean existsByBankAccount_Id(Long bankAccountId); @Modifying(clearAutomatically = true) @Query("UPDATE Goal g SET g.deletedAt = CURRENT_TIMESTAMP " + 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 index 0634b2b5..d8aa73d7 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryCustom.java +++ b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryCustom.java @@ -1,9 +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 index b5faa2cf..a9f81798 100644 --- a/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryImpl.java +++ b/src/main/java/org/umc/valuedi/domain/goal/repository/GoalRepositoryImpl.java @@ -3,7 +3,9 @@ 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; @@ -26,4 +28,41 @@ public Optional findByIdWithDetails(Long goalId) { 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 64991c18..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,15 +30,17 @@ public class GoalQueryService { private final GoalAchievementRateService achievementRateService; // ๋ชฉํ‘œ ์ƒ์„ธ ์กฐํšŒ - public GoalDetailResponseDto getGoalDetail(Long goalId) { - Goal goal = goalRepository.findByIdWithDetails(goalId) + public GoalDetailResponseDto getGoalDetail(Long memberId, Long goalId) { + Goal goal = goalRepository.findByIdAndMemberId(goalId, memberId) .orElseThrow(() -> new GoalException(GoalErrorCode.GOAL_NOT_FOUND)); - long savedAmount = 0; // ๊ณ„์ขŒ ์—ฐ๋™ ํ›„ ์ˆ˜์ • - if (goal.getBankAccount() != null && goal.getBankAccount().getBalanceAmount() != null) { - savedAmount = goal.getBankAccount().getBalanceAmount(); + 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); @@ -49,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/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/stop b/stop deleted file mode 100644 index b833c99c..00000000 --- a/stop +++ /dev/null @@ -1 +0,0 @@ -MySQL80 From 293db2b4a9cf5aae7c231693e48b133dba0143f2 Mon Sep 17 00:00:00 2001 From: dohee <152317074+seamooll@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:48:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[FEATURE/#54]=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=9E=90=EC=82=B0=20=EB=8F=99=EA=B8=B0=ED=99=94=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐Ÿ”— Related Issue - closes #54 ## ๐Ÿ“ Summary ์‚ฌ์šฉ์ž์˜ ๊ธˆ์œต ์ž์‚ฐ(์€ํ–‰ ๋‚ด์—ญ, ์นด๋“œ ๋‚ด์—ญ)์„ ํ•œ ๋ฒˆ์— ๊ฐ€์ ธ์˜ค๋Š” ์ „์ฒด ๋™๊ธฐํ™” API๋ฅผ ๊ตฌํ˜„ํ•˜์˜€์Šต๋‹ˆ๋‹ค. - ์ „์ฒด ์ž์‚ฐ ๋™๊ธฐํ™” API ๊ตฌํ˜„ ๋ฐ ์„ฑ๋Šฅ ๊ฐœ์„  - ์€ํ–‰/์นด๋“œ ๊ฑฐ๋ž˜ ๋‚ด์—ญ์„ ์™ธ๋ถ€ API๋กœ ์กฐํšŒ ํ›„ DB์— ์ €์žฅํ•˜๋Š” ์ „์ฒด ๋™๊ธฐํ™” ๋กœ์ง ๊ตฌํ˜„ - JPA saveAll() ๋Œ€์‹  JdbcTemplate ๊ธฐ๋ฐ˜ Bulk Insert ๋ฐฉ์‹ ์ ์šฉ (๋Œ€๋Ÿ‰ Insert ์‹œ ๋ฐœ์ƒํ•˜๋˜ ์„ฑ๋Šฅ ์ €ํ•˜ ๋ฌธ์ œ ๊ฐœ์„ ) - ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… ๋ฐ ์•ˆ์ •ํ™” ## ๐Ÿ”„ Changes - [X] API ๋ณ€๊ฒฝ (์ถ”๊ฐ€/์ˆ˜์ •) - [ ] ๋ฐ์ดํ„ฐ ๋ฐ ๋„๋ฉ”์ธ ๋ณ€๊ฒฝ (DB, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง) - [ ] ์„ค์ • ๋˜๋Š” ์ธํ”„๋ผ ๊ด€๋ จ ๋ณ€๊ฒฝ - [X] ๋ฆฌํŒฉํ† ๋ง ## ๐Ÿ’ฌ Questions & Review Points ## ๐Ÿ“ธ API Test Results (Swagger) image ## โœ… Checklist - [X] API ํ…Œ์ŠคํŠธ ์™„๋ฃŒ - [X] ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์‚ฌ์ง„ ์ฒจ๋ถ€ - [X] ๋นŒ๋“œ ์„ฑ๊ณต ํ™•์ธ (./gradlew build) --- .../asset/controller/AssetController.java | 12 + .../asset/controller/AssetControllerDocs.java | 48 ++++ .../domain/asset/dto/res/AssetResDTO.java | 29 +- .../domain/asset/entity/BankTransaction.java | 10 +- .../asset/exception/AssetException.java | 10 + .../asset/exception/code/AssetErrorCode.java | 18 ++ .../exception/code/AssetSuccessCode.java | 19 ++ .../BankTransactionRepository.java | 10 + .../BankTransactionRepositoryImpl.java | 4 +- .../repository/card/card/CardRepository.java | 2 + .../cardApproval/CardApprovalRepository.java | 11 + .../CardApprovalRepositoryImpl.java | 4 +- .../service/command/AssetFetchService.java | 147 ++++++++++ .../command/AssetSyncFacadeService.java | 49 ++++ .../service/command/AssetSyncProcessor.java | 50 ++++ .../service/command/AssetSyncService.java | 70 +---- .../command/worker/AssetFetchWorker.java | 108 ++++++++ .../service/ConnectionEventListener.java | 2 +- .../domain/ledger/entity/LedgerEntry.java | 3 +- .../repository/LedgerEntryRepository.java | 13 +- .../LedgerEntryRepositoryCustom.java | 8 + .../repository/LedgerEntryRepositoryImpl.java | 42 +++ .../service/command/LedgerSyncService.java | 252 ++++++------------ .../domain/mbti/entity/MbtiTypeInfo.java | 2 +- .../valuedi/domain/member/entity/Member.java | 7 + .../valuedi/global/config/AsyncConfig.java | 12 +- .../codef/config/CodefFeignConfig.java | 2 + .../codef/converter/CodefAssetConverter.java | 53 +++- .../codef/service/CodefAssetService.java | 35 ++- 29 files changed, 776 insertions(+), 256 deletions(-) create mode 100644 src/main/java/org/umc/valuedi/domain/asset/exception/AssetException.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/service/command/AssetFetchService.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncFacadeService.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncProcessor.java create mode 100644 src/main/java/org/umc/valuedi/domain/asset/service/command/worker/AssetFetchWorker.java create mode 100644 src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryCustom.java create mode 100644 src/main/java/org/umc/valuedi/domain/ledger/repository/LedgerEntryRepositoryImpl.java 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 3275f628..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.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") @@ -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 b76f3c05..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 @@ -319,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/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/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/bankTransaction/BankTransactionRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepository.java index 02d0f3cb..2a4e28ef 100644 --- 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 @@ -1,11 +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/bankTransaction/BankTransactionRepositoryImpl.java b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryImpl.java index f73d09bf..f183c577 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryImpl.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/bank/bankTransaction/BankTransactionRepositoryImpl.java @@ -3,6 +3,7 @@ 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/card/CardRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/card/card/CardRepository.java index fcd3d092..06a643a8 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/card/card/CardRepository.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/card/card/CardRepository.java @@ -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 index be58af96..2a52d005 100644 --- 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 @@ -1,13 +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/cardApproval/CardApprovalRepositoryImpl.java b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryImpl.java index 18c68887..64877888 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryImpl.java +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/card/cardApproval/CardApprovalRepositoryImpl.java @@ -3,6 +3,7 @@ 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/command/AssetSyncService.java b/src/main/java/org/umc/valuedi/domain/asset/service/command/AssetSyncService.java index bb847003..0d58ea3b 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/service/command/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.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; @@ -20,7 +18,6 @@ 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/connection/service/ConnectionEventListener.java b/src/main/java/org/umc/valuedi/domain/connection/service/ConnectionEventListener.java index e21d2f08..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 @@ -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/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/service/command/LedgerSyncService.java b/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java index de790c2a..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,7 +2,6 @@ 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; @@ -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) {