From cb8888d47a585dbc6b89bcf0e45a85e8b858d570 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:58:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EB=93=B1=EB=A1=9D=EB=90=9C=20=EC=86=8C?= =?UTF-8?q?=EB=B9=84=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=AC=B4=ED=95=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A1=B0=ED=9A=8C=20API=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * test: controller unit test 작성 * test: 카테고리별 지출 리스트 조회 api 경로 수정 * feat: 조회하려는 카테고리 타입 상수 정의 * feat: 지출 카테고리 타입 400 에러 추가 * feat: 지출 카테고리 타입 상수 web-config conveter 정의 후 등록 * test: spending-category-type 쿼리 파라미터 테스트 추가 * rename: 에러 상수 오타 수정 * test: 400 error -> 422 에러 수정 * feat: get-spendings-by-category controller 구현 * feat: 카테고리 별 지출 내역 조회 usecase 작성 * feat: 카테고리 타입에 따른 권한 검사 메서드 추가 * test: controller type, category_id 조합 검사 테스트 * fix: default type 카테고리에 대해 category-id 조건 검사 추가 * feat: 카테고리에 등록된 지출 내역 리스트 조회 메서드 추가 * feat: 사용자 정의 카테고리 아이디 & 시스템 제공 카테고리 code 기반 지출 내역 슬라이스 조회 메서드 추가 * feat: 타입, 카테고리 아이디 불일치 에러코드 추가 * fix: spending-search-service 내부에서 시스템 정의 카테고리 지출 내역 조회 시, 상수 타입 변환 * feat: 지출 카테고리 code 기반 상수 탐색 정적 팩토리 메서드 추가 * fix: 시스템 제공 카테고리의 지출 내역 조회 시, 도메인 서비스에서 타입 검사 조건문 추가 * feat: 커스텀 카테고리 아이디 & 카테고리 코드 별 지출 리스트 조회 repository 메서드 추가 * feat: 지출 entity to_string 재정의 * fix: 지출 기본 정렬 필드 created_at -> spend_at * rename: 디버깅용 로그 제거 * feat: mapper 내 카테고리별 지출 리스트 응답 dto 메서드 추가 && daily-list 정렬 메서드 추가 * feat: usecase 응답 시 mapper 호출 * style: usecase 내 주석 제거 * feat: 지출 월별 데이터 슬라이싱 응답 dto 정의 * fix: usecase & mapper 타입 수정 * rename: month-slice dto 필드명 months -> content * refactor: 도메인 서비스 내 custom-repository -> interface로 로직 수행 * test: usecase mock given절 처리 * refactor: spending-mapper map key 연산 시, year-month 객체를 사용하여 수정 * docs: 카테고리의 지출 리스트 조회 api 스웨거 문서 작성 * feat: 카테고리에 등록된 소비 내역 총 개수 조회 controller 추가 * docs: 카테고리 내 지출 총 개수 조회 api 스웨거 문서 작성 * feat: 카테고리 내 지출 총 개수 조회 usecase 추가 * feat: 카테고리 내 지출 리스트 총 개수 조회 도메인 서비스 & repository 메서드 추가 * feat: spending-search-service total count 확인 메서드 추가 * feat: 자원 인가 검사 추가 --- .../apis/ledger/api/SpendingCategoryApi.java | 67 ++++++++++++++ .../SpendingCategoryController.java | 41 ++++++++- .../apis/ledger/dto/SpendingSearchRes.java | 20 +++++ .../apis/ledger/mapper/SpendingMapper.java | 34 ++++++++ .../ledger/service/SpendingSearchService.java | 34 ++++++++ .../usecase/SpendingCategoryUseCase.java | 21 +++++ .../SpendingCategoryTypeConverter.java | 17 ++++ .../common/query/SpendingCategoryType.java | 12 +++ .../SpendingCategoryManager.java | 15 ++++ .../kr/co/pennyway/api/config/WebConfig.java | 3 +- .../GetSpendingsByCategoryControllerTest.java | 87 +++++++++++++++++++ .../domains/spending/domain/Spending.java | 11 +++ .../spending/exception/SpendingErrorCode.java | 3 +- .../SpendingCustomRepositoryImpl.java | 3 +- .../repository/SpendingRepository.java | 7 ++ .../spending/service/SpendingService.java | 57 ++++++++++++ .../spending/type/SpendingCategory.java | 9 ++ 17 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index d84f53220..add401638 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -6,12 +6,21 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "지출 카테고리 API") public interface SpendingCategoryApi { @@ -32,4 +41,62 @@ public interface SpendingCategoryApi { @Operation(summary = "사용자 정의 지출 카테고리 조회", method = "GET", description = "사용자가 생성한 지출 카테고리 목록을 조회합니다.") @ApiResponse(responseCode = "200", description = "지출 카테고리 조회 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategories", array = @ArraySchema(schema = @Schema(implementation = SpendingCategoryDto.Res.class))))) ResponseEntity getSpendingCategories(@AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "지출 카테고리에 등록된 지출 내역 총 개수 조회", method = "GET") + @Parameters({ + @Parameter(name = "categoryId", description = "type이 default면 아이콘 코드(1~11), custom이면 카테고리 pk", required = true, in = ParameterIn.PATH), + @Parameter(name = "type", description = "지출 카테고리 타입", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "기본", value = "default"), @ExampleObject(name = "사용자 정의", value = "custom") + }) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "지출 내역 총 개수 조회 성공", content = @Content(mediaType = "application/json", examples = @ExampleObject(name = "지출 내역 총 개수 조회 성공", value = """ + { + "totalCount": 10 + } + """))), + @ApiResponse(responseCode = "400", description = "type과 categoryId 미스 매치", content = @Content(examples = + @ExampleObject(name = "type과 categoryId가 유효하지 않은 조합", description = "type이 default면서, categoryId가 CUSTOM(0) 혹은 OTHER(12)일 수는 없다.", value = """ + { + "code": "4005", + "message": "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다." + } + """ + ))) + }) + ResponseEntity getSpendingTotalCountByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @AuthenticationPrincipal SecurityUserDetails user + ); + + @Operation(summary = "지출 카테고리에 등록된 지출 내역 조회", method = "GET", description = "지출 카테고리별 지출 내역을 조회하며, 무한 스크롤 응답이 반환됩니다.") + @Parameters({ + @Parameter(name = "categoryId", description = "type이 default면 아이콘 코드(1~11), custom이면 카테고리 pk", required = true, in = ParameterIn.PATH), + @Parameter(name = "type", description = "지출 카테고리 타입", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "기본", value = "default"), @ExampleObject(name = "사용자 정의", value = "custom") + }), + @Parameter(name = "size", description = "페이지 사이즈 (default: 30)", example = "30", in = ParameterIn.QUERY), + @Parameter(name = "page", description = "페이지 번호 (default: 0)", example = "0", in = ParameterIn.QUERY), + @Parameter(name = "sort", description = "정렬 기준 (default: sending.spendAt)", example = "spending.spendAt", in = ParameterIn.QUERY), + @Parameter(name = "direction", description = "정렬 방식 (default: DESC)", example = "DESC", in = ParameterIn.QUERY), + @Parameter(name = "pageable", hidden = true) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "지출 내역 조회 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendings", schema = @Schema(implementation = SpendingSearchRes.MonthSlice.class)))), + @ApiResponse(responseCode = "400", description = "type과 categoryId 미스 매치", content = @Content(examples = + @ExampleObject(name = "type과 categoryId가 유효하지 않은 조합", description = "type이 default면서, categoryId가 CUSTOM(0) 혹은 OTHER(12)일 수는 없다.", value = """ + { + "code": "4005", + "message": "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다." + } + """ + ))) + }) + ResponseEntity getSpendingsByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @PageableDefault(size = 30, page = 0) @SortDefault(sort = "spending.spendAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal SecurityUserDetails user + ); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index 076acbf5a..e40d0f355 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -3,6 +3,7 @@ import kr.co.pennyway.api.apis.ledger.api.SpendingCategoryApi; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; @@ -10,14 +11,15 @@ import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -44,4 +46,35 @@ public ResponseEntity postSpendingCategory(@Validated SpendingCategoryDto.Cre public ResponseEntity getSpendingCategories(@AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("spendingCategories", spendingCategoryUseCase.getSpendingCategories(user.getUserId()))); } + + @Override + @GetMapping("/{categoryId}/spendings/count") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(#user.getUserId(), #categoryId, #type)") + public ResponseEntity getSpendingTotalCountByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @AuthenticationPrincipal SecurityUserDetails user + ) { + if (type.equals(SpendingCategoryType.DEFAULT) && (categoryId.equals(0L) || categoryId.equals(12L))) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_TYPE_WITH_CATEGORY_ID); + } + + return ResponseEntity.ok(SuccessResponse.from("totalCount", spendingCategoryUseCase.getSpendingTotalCountByCategory(user.getUserId(), categoryId, type))); + } + + @Override + @GetMapping("/{categoryId}/spendings") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(#user.getUserId(), #categoryId, #type)") + public ResponseEntity getSpendingsByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @PageableDefault(size = 30, page = 0) @SortDefault(sort = "spending.spendAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal SecurityUserDetails user + ) { + if (type.equals(SpendingCategoryType.DEFAULT) && (categoryId.equals(0L) || categoryId.equals(12L))) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_TYPE_WITH_CATEGORY_ID); + } + + return ResponseEntity.ok(SuccessResponse.from("spendings", spendingCategoryUseCase.getSpendingsByCategory(user.getUserId(), categoryId, pageable, type))); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java index 6b85dbd6e..10e6a0cd9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java @@ -7,12 +7,32 @@ import jakarta.validation.constraints.NotNull; import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; import lombok.Builder; +import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; import java.util.List; import java.util.Objects; public class SpendingSearchRes { + @Builder + @Schema(title = "월별 지출 내역 조회 슬라이스 응답") + public record MonthSlice( + @Schema(description = "년/월별 지출 내역") + List content, + @Schema(description = "현재 페이지 번호") + int currentPageNumber, + @Schema(description = "페이지 크기") + int pageSize, + @Schema(description = "전체 요소 개수") + int numberOfElements, + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext + ) { + public static MonthSlice from(List months, Pageable pageable, int numberOfElements, boolean hasNext) { + return new MonthSlice(months, pageable.getPageNumber(), pageable.getPageSize(), numberOfElements, hasNext); + } + } + @Builder @Schema(title = "월별 지출 내역 조회 응답") public record Month( diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java index 6c57bdc73..62921b0f8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java @@ -3,18 +3,47 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.common.annotation.Mapper; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import org.springframework.data.domain.Slice; +import java.time.YearMonth; +import java.util.Comparator; import java.util.List; import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; @Mapper public class SpendingMapper { + /** + * Slice 객체를 받아 년/월/일 별로 지출 내역을 그룹화 및 정렬화 후 {@link SpendingSearchRes.MonthSlice}로 변환하는 메서드 + */ + public static SpendingSearchRes.MonthSlice toMonthSlice(Slice spendings) { + List spendingList = spendings.getContent(); + + // 연도와 월별로 그룹화 + ConcurrentMap> groupSpendingsByYearAndMonth = spendingList.stream() + .collect(Collectors.groupingByConcurrent(spending -> YearMonth.of(spending.getSpendAt().getYear(), spending.getSpendAt().getMonthValue()))); + + // 그룹화된 결과를 Month 객체로 변환하고, 년-월 순으로 역정렬 + List months = groupSpendingsByYearAndMonth.entrySet().stream() + .map(entry -> toSpendingSearchResMonth(entry.getValue(), entry.getKey().getYear(), entry.getKey().getMonthValue())) + .sorted(Comparator.comparing(SpendingSearchRes.Month::year) + .thenComparing(SpendingSearchRes.Month::month) + .reversed()) + .toList(); + + return SpendingSearchRes.MonthSlice.from(months, spendings.getPageable(), spendings.getNumberOfElements(), spendings.hasNext()); + } + + /** + * 년/월 별로 지출 내역을 그룹화 및 정렬화 후 {@link SpendingSearchRes.Month}로 변환하는 메서드 + */ public static SpendingSearchRes.Month toSpendingSearchResMonth(List spendings, int year, int month) { ConcurrentMap> groupSpendingsByDay = spendings.stream().collect(Collectors.groupingByConcurrent(Spending::getDay)); + // 그룹화된 결과를 Daily 객체로 변환하고, 일(day)을 기준으로 역정렬 List dailySpendings = groupSpendingsByDay.entrySet().stream() .map(entry -> toSpendingSearchResDaily(entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(SpendingSearchRes.Daily::day).reversed()) .toList(); return SpendingSearchRes.Month.builder() @@ -24,9 +53,14 @@ public static SpendingSearchRes.Month toSpendingSearchResMonth(List sp .build(); } + /** + * 일 별로 지출 내역을 정렬 후 {@link SpendingSearchRes.Daily}로 변환하는 메서드 + */ private static SpendingSearchRes.Daily toSpendingSearchResDaily(int day, List spendings) { + // 지출 내역을 id 순으로 정렬 List individuals = spendings.stream() .map(SpendingMapper::toSpendingSearchResIndividual) + .sorted(Comparator.comparing(SpendingSearchRes.Individual::id)) .toList(); return SpendingSearchRes.Daily.builder() diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java index 7d1890e49..f11843a0c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java @@ -1,12 +1,16 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +34,36 @@ public List readSpendingsAtYearAndMonth(Long userId, int year, int mon return spendingService.readSpendings(userId, year, month); } + /** + * 카테고리에 등록된 지출 내역 개수를 조회한다. + */ + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId, SpendingCategoryType type) { + if (type.equals(SpendingCategoryType.CUSTOM)) { + return spendingService.readSpendingTotalCountByCategoryId(userId, categoryId); + } + + SpendingCategory spendingCategory = SpendingCategory.fromCode(categoryId.toString()); + return spendingService.readSpendingTotalCountByCategory(userId, spendingCategory); + } + + /** + * 카테고리에 등록된 지출 내역 리스트를 조회한다. + * + * @param categoryId type이 {@link SpendingCategoryType#CUSTOM}이면 커스텀 카테고리 아이디, {@link SpendingCategoryType#DEFAULT}이면 시스템 제공 카테고리 코드로 사용한다. + * @param type {@link SpendingCategoryType#CUSTOM}이면 커스텀 카테고리, {@link SpendingCategoryType#DEFAULT}이면 시스템 제공 카테고리에 대한 쿼리를 호출한다. + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsByCategoryId(Long userId, Long categoryId, Pageable pageable, SpendingCategoryType type) { + if (type.equals(SpendingCategoryType.CUSTOM)) { + return spendingService.readSpendingsSliceByCategoryId(userId, categoryId, pageable); + } + + SpendingCategory spendingCategory = SpendingCategory.fromCode(categoryId.toString()); + return spendingService.readSpendingsSliceByCategory(userId, spendingCategory, pageable); + } + @Transactional(readOnly = true) public Optional readTotalSpendingAmountByUserIdThatMonth(Long userId, LocalDate date) { return spendingService.readTotalSpendingAmountByUserId(userId, date); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java index 2ecb83c85..c1d94f460 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -1,14 +1,21 @@ package kr.co.pennyway.api.apis.ledger.usecase; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingCategoryMapper; +import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySaveService; import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySearchService; +import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -20,6 +27,8 @@ public class SpendingCategoryUseCase { private final SpendingCategorySaveService spendingCategorySaveService; private final SpendingCategorySearchService spendingCategorySearchService; + private final SpendingSearchService spendingSearchService; + @Transactional public SpendingCategoryDto.Res createSpendingCategory(Long userId, String categoryName, SpendingCategory icon) { SpendingCustomCategory category = spendingCategorySaveService.execute(userId, categoryName, icon); @@ -33,4 +42,16 @@ public List getSpendingCategories(Long userId) { return SpendingCategoryMapper.toResponses(categories); } + + @Transactional(readOnly = true) + public int getSpendingTotalCountByCategory(Long userId, Long categoryId, SpendingCategoryType type) { + return spendingSearchService.readSpendingTotalCountByCategoryId(userId, categoryId, type); + } + + @Transactional(readOnly = true) + public SpendingSearchRes.MonthSlice getSpendingsByCategory(Long userId, Long categoryId, Pageable pageable, SpendingCategoryType type) { + Slice spendings = spendingSearchService.readSpendingsByCategoryId(userId, categoryId, pageable, type); + + return SpendingMapper.toMonthSlice(spendings); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java new file mode 100644 index 000000000..a46b15e24 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.common.converter; + +import kr.co.pennyway.api.common.query.SpendingCategoryType; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import org.springframework.core.convert.converter.Converter; + +public class SpendingCategoryTypeConverter implements Converter { + @Override + public SpendingCategoryType convert(String type) { + try { + return SpendingCategoryType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_CATEGORY_TYPE); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java new file mode 100644 index 000000000..048164750 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.api.common.query; + +public enum SpendingCategoryType { + DEFAULT("default"), + CUSTOM("custom"); + + private final String type; + + SpendingCategoryType(String type) { + this.type = type; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java index a8e9aa81e..f3d0ece74 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.common.security.authorization; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,4 +27,18 @@ public boolean hasPermission(Long userId, Long categoryId) { return spendingCustomCategoryService.isExistsSpendingCustomCategory(userId, categoryId); } + + /** + * 사용자가 지출 카테고리에 대한 권한이 있는지 확인한다. + * {@link SpendingCategoryType#CUSTOM}이면 {@link #hasPermission(Long, Long)}를 호출한다. + * {@link SpendingCategoryType#DEFAULT}면, 시스템 제공 카테고리이므로 권한 검사를 수행하지 않는다. + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long categoryId, SpendingCategoryType type) { + if (type.equals(SpendingCategoryType.CUSTOM)) { + return hasPermission(userId, categoryId); + } + + return true; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java index 3040f7d8f..fbac3f61e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.common.converter.NotifyTypeConverter; import kr.co.pennyway.api.common.converter.ProviderConverter; +import kr.co.pennyway.api.common.converter.SpendingCategoryTypeConverter; import kr.co.pennyway.api.common.converter.VerificationTypeConverter; import kr.co.pennyway.api.common.interceptor.SignEventLogInterceptor; import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; @@ -20,10 +21,10 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registrar) { - registrar.addConverter(new ProviderConverter()); registrar.addConverter(new VerificationTypeConverter()); registrar.addConverter(new NotifyTypeConverter()); + registrar.addConverter(new SpendingCategoryTypeConverter()); } @Override diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java new file mode 100644 index 000000000..d0f70445e --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java @@ -0,0 +1,87 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.common.query.SpendingCategoryType; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.ArrayList; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = SpendingCategoryController.class) +@ActiveProfiles("test") +public class GetSpendingsByCategoryControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private SpendingCategoryUseCase spendingCategoryUseCase; + @MockBean + private SignEventLogService signEventLogService; + @MockBean + private JwtProvider accessTokenProvider; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(MockMvcRequestBuilders.get("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("default, custom 타입은 올바르게 조회된다.") + @WithSecurityMockUser + void getSpendingsByCategory() throws Exception { + given(spendingCategoryUseCase.getSpendingsByCategory(any(), any(), any(), any())).willReturn(new SpendingSearchRes.MonthSlice(new ArrayList<>(), 0, 0, 0, false)); + + performGetSpendingsByCategory(1L, SpendingCategoryType.DEFAULT.name()) + .andDo(print()) + .andExpect(status().isOk()); + performGetSpendingsByCategory(1L, SpendingCategoryType.CUSTOM.name().toLowerCase()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("잘못된 타입을 조회하면 422 에러가 발생한다.") + void getSpendingsByCategory_InvalidType() throws Exception { + performGetSpendingsByCategory(1L, "invalid") + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("카테고리 타입이 default이면서 categoryId가 0이거나 12이면 400 에러가 발생한다.") + void getSpendingsByCategory_InvalidCategoryId() throws Exception { + performGetSpendingsByCategory(0L, SpendingCategoryType.DEFAULT.name()) + .andDo(print()) + .andExpect(status().isBadRequest()); + performGetSpendingsByCategory(12L, SpendingCategoryType.DEFAULT.name()) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + private ResultActions performGetSpendingsByCategory(Long categoryId, String type) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/spending-categories/{categoryId}/spendings", categoryId) + .param("type", type)); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index 410d3a136..bdad51667 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -94,4 +94,15 @@ public void update(Integer amount, SpendingCategory category, LocalDateTime spen this.memo = memo; this.spendingCustomCategory = spendingCustomCategory; } + + @Override + public String toString() { + return "Spending{" + + "id=" + id + + ", amount=" + amount + + ", category=" + category + + ", spendAt=" + spendAt + + ", accountName='" + accountName + '\'' + + ", memo='" + memo + "'}"; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java index 992599488..889ac2db1 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -13,12 +13,13 @@ public enum SpendingErrorCode implements BaseErrorCode { /* 400 Bad Request */ INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."), INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_TYPE_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_CATEGORY_TYPE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "존재하지 않는 카테고리 타입입니다."), /* 404 Not Found */ NOT_FOUND_SPENDING(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 지출 내역입니다."), NOT_FOUND_CUSTOM_CATEGORY(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 커스텀 카테고리입니다."); - private final StatusCode statusCode; private final ReasonCode reasonCode; private final String message; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 1c56e65cf..12bacd057 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -10,12 +10,14 @@ import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +@Slf4j @Repository @RequiredArgsConstructor public class SpendingCustomRepositoryImpl implements SpendingCustomRepository { @@ -59,5 +61,4 @@ public List findByYearAndMonth(Long userId, int year, int month) { .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) .fetch(); } - } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index 90fc7a6e3..2ba3bd04d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -2,9 +2,16 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import org.springframework.transaction.annotation.Transactional; public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository { @Transactional(readOnly = true) boolean existsByIdAndUser_Id(Long id, Long userId); + + @Transactional(readOnly = true) + int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId); + + @Transactional(readOnly = true) + int countByUser_IdAndCategory(Long userId, SpendingCategory spendingCategory); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 233c60e6b..b3b7e7698 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -4,13 +4,18 @@ import com.querydsl.core.types.Predicate; import kr.co.pennyway.common.annotation.DomainService; import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.common.util.SliceUtil; import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +33,7 @@ public class SpendingService { private final QUser user = QUser.user; private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; @Transactional public Spending createSpending(Spending spending) { @@ -49,6 +55,57 @@ public List readSpendings(Long userId, int year, int month) { return spendingRepository.findByYearAndMonth(userId, year, month); } + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId) { + return spendingRepository.countByUser_IdAndSpendingCustomCategory_Id(userId, categoryId); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategory(Long userId, SpendingCategory spendingCategory) { + return spendingRepository.countByUser_IdAndCategory(userId, spendingCategory); + } + + /** + * 사용자 정의 카테고리 ID로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategoryId(Long userId, Long categoryId, Pageable pageable) { + Predicate predicate = spending.user.id.eq(userId).and(spendingCustomCategory.id.eq(categoryId)); + + QueryHandler queryHandler = query -> query + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + + /** + * 시스템 제공 카테고리 code로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategory(Long userId, SpendingCategory spendingCategory, Pageable pageable) { + if (spendingCategory.equals(SpendingCategory.CUSTOM) || spendingCategory.equals(SpendingCategory.OTHER)) { + throw new IllegalArgumentException("지출 카테고리가 시스템 제공 카테고리가 아닙니다."); + } + + Predicate predicate = spending.user.id.eq(userId).and(spending.category.eq(spendingCategory)); + + QueryHandler queryHandler = query -> query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + @Transactional(readOnly = true) public List readTotalSpendingsAmountByUserId(Long userId) { Predicate predicate = user.id.eq(userId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java index 3c0bdbce4..63734ef0f 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.stream.Stream; + @Getter @RequiredArgsConstructor public enum SpendingCategory implements LegacyCommonType { @@ -23,4 +25,11 @@ public enum SpendingCategory implements LegacyCommonType { private final String code; private final String type; + + public static SpendingCategory fromCode(String code) { + return Stream.of(values()) + .filter(v -> v.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리 코드입니다.")); + } }