Skip to content

Commit

Permalink
feat: ✨ 소비내역 복수 삭제 API (#119)
Browse files Browse the repository at this point in the history
* feat: controller 및 인가 처리 메서드 작성

* feat: usecase 작성

* feat: query dsl을 활용한 비즈니스 로직 작성

* feat: dto 작성

* fix: deleteallbyid 사용하게 변경

* fix: 권한 검사 구문 수정

* test: 소비내역 복수 삭제 통합 테스트 작성

* fix: dto 빈값 검증 수정

* docs: swagger 문서 작성

* fix: 권한 검사 로직 성능 개선을 위한 쿼리 수정

* fix: 삭제 연산 쿼리 횟수 개선을 위한 jpql 사용

* fix: 쿼리 연산을 구분하기 위한 메서드명 변경

* fix: 쿼리 연산을 구분하기 위한 domain 레벨 메서드 명 변경

* fix: repository 반환타입 long으로 통일

* fix; 암묵적 형변환을 지양하기 위한 명시적 형변환 추가
  • Loading branch information
asn6878 authored Jul 4, 2024
1 parent adbaeed commit 74e0081
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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.SpendingIdsDto;
import kr.co.pennyway.api.apis.ledger.dto.SpendingReq;
import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
Expand Down Expand Up @@ -88,8 +89,8 @@ public interface SpendingApi {

@Operation(summary = "지출 내역 삭제", method = "DELETE", description = "지출 내역의 ID값으로 해당 지출 내역을 삭제 합니다.")
@Parameter(name = "spendingId", description = "지출 내역 ID", example = "1", required = true, in = ParameterIn.PATH)
@ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = {
@ExampleObject(name = "지출 카테고리 권한 오류", description = "지출 카테고리에 대한 권한이 없습니다.",
@ApiResponse(responseCode = "403", description = "지출 내역에 대한 권한이 없습니다.", content = @Content(examples = {
@ExampleObject(name = "지출 내역 권한 오류", description = "지출 내역에 대한 권한이 없습니다.",
value = """
{
"code": "4030",
Expand All @@ -99,4 +100,18 @@ public interface SpendingApi {
)
}))
ResponseEntity<?> deleteSpending(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user);


@Operation(summary = "지출 내역 복수 삭제", method = "DELETE", description = "사용자의 지출 내역의 ID목록으로 해당 지출 내역들을 삭제 합니다.")
@ApiResponse(responseCode = "403", description = "지출 내역에 대한 권한이 없습니다.", content = @Content(examples = {
@ExampleObject(name = "지출 내역 권한 오류", description = "지출 내역에 대한 권한이 없습니다.",
value = """
{
"code": "4030",
"message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN"
}
"""
)
}))
ResponseEntity<?> deleteSpendings(@RequestBody SpendingIdsDto spendingIds, @AuthenticationPrincipal SecurityUserDetails user);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kr.co.pennyway.api.apis.ledger.controller;

import kr.co.pennyway.api.apis.ledger.api.SpendingApi;
import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto;
import kr.co.pennyway.api.apis.ledger.dto.SpendingReq;
import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase;
import kr.co.pennyway.api.common.response.SuccessResponse;
Expand Down Expand Up @@ -70,6 +71,14 @@ public ResponseEntity<?> deleteSpending(@PathVariable Long spendingId, @Authenti
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Override
@DeleteMapping("")
@PreAuthorize("isAuthenticated() and @spendingManager.hasPermissions(#user.getUserId(), #spendingIds.spendingIds())")
public ResponseEntity<?> deleteSpendings(@RequestBody SpendingIdsDto spendingIds, @AuthenticationPrincipal SecurityUserDetails user) {
spendingUseCase.deleteSpendings(spendingIds.spendingIds());
return ResponseEntity.ok(SuccessResponse.noContent());
}

/**
* categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM이나 OTHER이 될 수 없고, <br/>
* categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM임을 확인한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.co.pennyway.api.apis.ledger.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;

import java.util.List;

public record SpendingIdsDto(
@Schema(description = "지출 내역 ID 목록")
@NotEmpty(message = "지출 내역 ID 목록은 필수입니다.")
List<Long> spendingIds
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
Expand All @@ -22,4 +24,9 @@ public void deleteSpending(Long spendingId) {

spendingService.deleteSpending(spending);
}

@Transactional
public void deleteSpendings(List<Long> spendingIds) {
spendingService.deleteSpendingsInQuery(spendingIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ public SpendingSearchRes.Individual updateSpending(Long spendingId, SpendingReq
return SpendingMapper.toSpendingSearchResIndividual(updatedSpending);
}

@Transactional
public void deleteSpending(Long spendingId) {
spendingDeleteService.deleteSpending(spendingId);
}

@Transactional
public void deleteSpendings(List<Long> spendingIds) {
spendingDeleteService.deleteSpendings(spendingIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Component("spendingManager")
@RequiredArgsConstructor
Expand All @@ -21,5 +23,14 @@ public class SpendingManager {
public boolean hasPermission(Long userId, Long spendingId) {
return spendingService.isExistsSpending(userId, spendingId);
}

@Transactional(readOnly = true)
public boolean hasPermissions(Long userId, List<Long> spendingIds) {
if (spendingService.countByUserIdAndIdIn(userId, spendingIds) != (long) spendingIds.size()) {
return false;
}

return true;
}
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kr.co.pennyway.api.apis.ledger.integration;

import com.fasterxml.jackson.databind.ObjectMapper;
import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto;
import kr.co.pennyway.api.apis.ledger.dto.SpendingReq;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
Expand All @@ -26,6 +27,8 @@
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
Expand Down Expand Up @@ -315,4 +318,72 @@ private ResultActions performDeleteSpendingSuccess(User requestUser, Long spendi
.with(user(userDetails)));
}
}

@Order(6)
@Nested
@DisplayName("지출 내역 복수 삭제")
class DeleteSpendings {

@Test
@DisplayName("지출 내역 복수 삭제 성공")
@Transactional
void deleteSpendingsSuccess() throws Exception {
// given
User user = userService.createUser(UserFixture.GENERAL_USER.toUser());
List<Long> spendingIds = new ArrayList<>();

for (int i = 0; i < 5; i++) {
Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user);
spendingService.createSpending(spending);
spendingIds.add(spending.getId());
}
SpendingIdsDto spendingIdsDto = new SpendingIdsDto(spendingIds);

// when
ResultActions resultActions = performDeleteSpendings(user, spendingIdsDto);

// then
resultActions
.andDo(print())
.andExpect(status().isOk());

for (Long id : spendingIds) {
Assertions.assertTrue(spendingService.readSpending(id).isEmpty());
}
}

@Test
@DisplayName("spendingIds에 하나라도 소유하지 않은 지출 내역이 포함되어 있을 경우, 403 Forbidden을 반환한다.")
@Transactional
void deleteSpendingsForbidden() throws Exception {
// given
User user = userService.createUser(UserFixture.GENERAL_USER.toUser());
User user2 = userService.createUser(UserFixture.GENERAL_USER.toUser());
List<Long> spendingIds = new ArrayList<>();

for (int i = 0; i < 5; i++) {
Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user);
spendingService.createSpending(spending);
spendingIds.add(spending.getId());
}
SpendingIdsDto spendingIdsDto = new SpendingIdsDto(spendingIds);

// when
ResultActions resultActions = performDeleteSpendings(user2, spendingIdsDto);

// then
resultActions
.andDo(print())
.andExpect(status().isForbidden());
}

private ResultActions performDeleteSpendings(User requestUser, SpendingIdsDto req) throws Exception {
UserDetails userDetails = SecurityUserDetails.from(requestUser);

return mockMvc.perform(MockMvcRequestBuilders.delete("/v2/spendings", req)
.contentType("application/json")
.with(user(userDetails))
.content(objectMapper.writeValueAsString(req)));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@
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.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

public interface SpendingRepository extends ExtendedRepository<Spending, Long>, 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);

@Transactional(readOnly = true)
long countByUserIdAndIdIn(Long userId, List<Long> spendingIds);

@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds AND s.deletedAt IS NULL")
void deleteAllByIdAndDeletedAtNullInQuery(List<Long> spendingIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,14 @@ public boolean isExistsSpending(Long userId, Long spendingId) {
public void deleteSpending(Spending spending) {
spendingRepository.delete(spending);
}

@Transactional
public void deleteSpendingsInQuery(List<Long> spendingIds) {
spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds);
}

@Transactional(readOnly = true)
public long countByUserIdAndIdIn(Long userId, List<Long> spendingIds) {
return spendingRepository.countByUserIdAndIdIn(userId, spendingIds);
}
}

0 comments on commit 74e0081

Please sign in to comment.