Skip to content

Commit

Permalink
feat: ✨ 카테고리에 등록된 소비 리스트 무한 스크롤 조회 API (#120)
Browse files Browse the repository at this point in the history
* 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: 자원 인가 검사 추가
  • Loading branch information
psychology50 authored Jul 3, 2024
1 parent e9d2cc5 commit cb8888d
Show file tree
Hide file tree
Showing 17 changed files with 434 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@
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;
import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException;
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
Expand All @@ -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)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Month> content,
@Schema(description = "현재 페이지 번호")
int currentPageNumber,
@Schema(description = "페이지 크기")
int pageSize,
@Schema(description = "전체 요소 개수")
int numberOfElements,
@Schema(description = "다음 페이지 존재 여부")
boolean hasNext
) {
public static MonthSlice from(List<Month> months, Pageable pageable, int numberOfElements, boolean hasNext) {
return new MonthSlice(months, pageable.getPageNumber(), pageable.getPageSize(), numberOfElements, hasNext);
}
}

@Builder
@Schema(title = "월별 지출 내역 조회 응답")
public record Month(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Spending> spendings) {
List<Spending> spendingList = spendings.getContent();

// 연도와 월별로 그룹화
ConcurrentMap<YearMonth, List<Spending>> groupSpendingsByYearAndMonth = spendingList.stream()
.collect(Collectors.groupingByConcurrent(spending -> YearMonth.of(spending.getSpendAt().getYear(), spending.getSpendAt().getMonthValue())));

// 그룹화된 결과를 Month 객체로 변환하고, 년-월 순으로 역정렬
List<SpendingSearchRes.Month> 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<Spending> spendings, int year, int month) {
ConcurrentMap<Integer, List<Spending>> groupSpendingsByDay = spendings.stream().collect(Collectors.groupingByConcurrent(Spending::getDay));

// 그룹화된 결과를 Daily 객체로 변환하고, 일(day)을 기준으로 역정렬
List<SpendingSearchRes.Daily> dailySpendings = groupSpendingsByDay.entrySet().stream()
.map(entry -> toSpendingSearchResDaily(entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(SpendingSearchRes.Daily::day).reversed())
.toList();

return SpendingSearchRes.Month.builder()
Expand All @@ -24,9 +53,14 @@ public static SpendingSearchRes.Month toSpendingSearchResMonth(List<Spending> sp
.build();
}

/**
* 일 별로 지출 내역을 정렬 후 {@link SpendingSearchRes.Daily}로 변환하는 메서드
*/
private static SpendingSearchRes.Daily toSpendingSearchResDaily(int day, List<Spending> spendings) {
// 지출 내역을 id 순으로 정렬
List<SpendingSearchRes.Individual> individuals = spendings.stream()
.map(SpendingMapper::toSpendingSearchResIndividual)
.sorted(Comparator.comparing(SpendingSearchRes.Individual::id))
.toList();

return SpendingSearchRes.Daily.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -30,6 +34,36 @@ public List<Spending> 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<Spending> 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<TotalSpendingAmount> readTotalSpendingAmountByUserIdThatMonth(Long userId, LocalDate date) {
return spendingService.readTotalSpendingAmountByUserId(userId, date);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand All @@ -33,4 +42,16 @@ public List<SpendingCategoryDto.Res> 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<Spending> spendings = spendingSearchService.readSpendingsByCategoryId(userId, categoryId, pageable, type);

return SpendingMapper.toMonthSlice(spendings);
}
}
Loading

0 comments on commit cb8888d

Please sign in to comment.