From 6e61abd15281bb94d3b44fb376f9a0b904208fa7 Mon Sep 17 00:00:00 2001 From: si-zero Date: Fri, 24 Jul 2026 23:51:32 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=201=EC=9D=B8=201=EA=B4=80=EC=A0=90=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EC=9D=84=20=ED=95=B4=EC=A0=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=201=EC=9D=B8=20=EB=8B=A4=EC=88=98=20=EA=B4=80?= =?UTF-8?q?=EC=A0=90=20=EB=93=B1=EB=A1=9D=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PerspectiveController.java | 6 ++- .../perspective/entity/Perspective.java | 6 +-- .../repository/PerspectiveRepository.java | 5 +- .../service/PerspectiveService.java | 39 +++++++------- .../global/common/exception/ErrorCode.java | 1 - ...2__drop_perspectives_unique_constraint.sql | 20 +++++++ .../service/PerspectiveServiceTest.java | 52 +++++++++++++++++++ 7 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 src/main/resources/db/migration/V20260724_02__drop_perspectives_unique_constraint.sql diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java index 9aaf97b4..842cc265 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -24,6 +24,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "관점 API", description = "관점 생성, 조회, 수정, 삭제") @RestController @RequestMapping("/api/v1") @@ -63,9 +65,9 @@ public ApiResponse getPerspectives( return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionId, sort)); } - @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점을 조회합니다.") + @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점 목록을 조회합니다.") @GetMapping("/battles/{battleId}/perspectives/me") - public ApiResponse getMyPerspective( + public ApiResponse> getMyPerspective( @PathVariable Long battleId, @AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(perspectiveService.getMyPerspective(battleId, userId)); diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java b/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java index 4456b0ae..7ccefa46 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java @@ -13,7 +13,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -21,10 +20,7 @@ @Getter @Entity -@Table( - name = "perspectives", - uniqueConstraints = @UniqueConstraint(columnNames = {"battle_id", "user_id"}) -) +@Table(name = "perspectives") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Perspective extends BaseEntity { diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java index c2a683ef..c95f04f6 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java @@ -7,13 +7,10 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; public interface PerspectiveRepository extends JpaRepository { - boolean existsByBattleIdAndUserId(Long battleId, Long userId); - - Optional findByBattleIdAndUserId(Long battleId, Long userId); + List findByBattleIdAndUserIdOrderByCreatedAtDesc(Long battleId, Long userId); List findByBattleIdAndStatusOrderByCreatedAtDesc(Long battleId, PerspectiveStatus status, Pageable pageable); diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java index d987e521..1688d09b 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java @@ -85,10 +85,6 @@ public CreatePerspectiveResponse createPerspective(Long battleId, Long userId, C User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - if (perspectiveRepository.existsByBattleIdAndUserId(battleId, userId)) { - throw new CustomException(ErrorCode.PERSPECTIVE_ALREADY_EXISTS); - } - Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, userId); BattleOption option = battleService.findOptionById(postVoteOptionId); @@ -181,27 +177,30 @@ public UpdatePerspectiveResponse updatePerspective(Long perspectiveId, Long user return new UpdatePerspectiveResponse(perspective.getId(), perspective.getContent(), perspective.getUpdatedAt()); } - public MyPerspectiveResponse getMyPerspective(Long battleId, Long userId) { + public List getMyPerspective(Long battleId, Long userId) { battleService.findById(battleId); - Perspective perspective = perspectiveRepository.findByBattleIdAndUserId(battleId, userId) - .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + List perspectives = perspectiveRepository.findByBattleIdAndUserIdOrderByCreatedAtDesc(battleId, userId); UserSummary user = userQueryService.findSummaryById(userId); String characterImageUrl = resolveCharacterImageUrl(user.characterType()); - BattleOption option = perspective.getOption(); - boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(perspective, userId); - return new MyPerspectiveResponse( - perspective.getId(), - new MyPerspectiveResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), - new MyPerspectiveResponse.OptionSummary(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle(), option.getStance()), - perspective.getContent(), - perspective.getLikeCount(), - perspective.getCommentCount(), - isLiked, - perspective.getStatus(), - perspective.getCreatedAt() - ); + return perspectives.stream() + .map(perspective -> { + BattleOption option = perspective.getOption(); + boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(perspective, userId); + return new MyPerspectiveResponse( + perspective.getId(), + new MyPerspectiveResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), + new MyPerspectiveResponse.OptionSummary(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle(), option.getStance()), + perspective.getContent(), + perspective.getLikeCount(), + perspective.getCommentCount(), + isLiked, + perspective.getStatus(), + perspective.getCreatedAt() + ); + }) + .toList(); } @Transactional diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 0c8b1198..c0b5dedd 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -80,7 +80,6 @@ public enum ErrorCode { // Perspective PERSPECTIVE_NOT_FOUND(HttpStatus.NOT_FOUND, "PERSPECTIVE_404", "존재하지 않는 관점입니다."), - PERSPECTIVE_ALREADY_EXISTS(HttpStatus.CONFLICT, "PERSPECTIVE_409", "이미 관점을 작성한 배틀입니다."), PERSPECTIVE_FORBIDDEN(HttpStatus.FORBIDDEN, "PERSPECTIVE_403", "본인 관점만 수정/삭제할 수 있습니다."), PERSPECTIVE_POST_VOTE_REQUIRED(HttpStatus.CONFLICT, "PERSPECTIVE_VOTE_409", "사후 투표가 완료되지 않았습니다."), PERSPECTIVE_MODERATION_NOT_FAILED(HttpStatus.BAD_REQUEST, "PERSPECTIVE_400", "검수 실패 상태의 관점이 아닙니다."), diff --git a/src/main/resources/db/migration/V20260724_02__drop_perspectives_unique_constraint.sql b/src/main/resources/db/migration/V20260724_02__drop_perspectives_unique_constraint.sql new file mode 100644 index 00000000..70990822 --- /dev/null +++ b/src/main/resources/db/migration/V20260724_02__drop_perspectives_unique_constraint.sql @@ -0,0 +1,20 @@ +-- 1인 1관점 제한 해제: perspectives(battle_id, user_id) 유니크 제약 제거 +-- Hibernate가 이름을 자동 생성해 환경마다 다를 수 있으므로 이름에 의존하지 않고 동적으로 찾아 드롭한다. +DO $$ +DECLARE + found_constraint_name text; +BEGIN + SELECT tc.constraint_name INTO found_constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + WHERE tc.table_name = 'perspectives' + AND tc.constraint_type = 'UNIQUE' + GROUP BY tc.constraint_name + HAVING array_agg(kcu.column_name ORDER BY kcu.column_name) = ARRAY['battle_id', 'user_id']; + + IF found_constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE perspectives DROP CONSTRAINT %I', found_constraint_name); + END IF; +END $$; diff --git a/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java b/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java index 8f36e2c4..43697a0e 100644 --- a/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java @@ -5,6 +5,9 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.perspective.dto.request.CreatePerspectiveRequest; +import com.swyp.picke.domain.perspective.dto.response.CreatePerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.MyPerspectiveResponse; import com.swyp.picke.domain.perspective.dto.response.PerspectiveDetailResponse; import com.swyp.picke.domain.perspective.entity.Perspective; import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; @@ -19,6 +22,7 @@ import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; import java.time.LocalDate; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -90,6 +94,54 @@ void getPerspectiveDetail_returns_option_opinion_as_label() { assertThat(response.option().title()).isEqualTo("예술이 아니다"); } + @Test + @DisplayName("이미 관점을 남긴 배틀에도 새 관점을 추가로 등록할 수 있다") + void createPerspective_allows_multiple_perspectives_for_same_user_and_battle() { + Battle battle = battle(100L); + User user = user(1L); + BattleOption option = option(10L, battle, BattleOptionLabel.A, "예술이다"); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(battleVoteService.findPostVoteOptionId(100L, 1L)).thenReturn(10L); + when(battleService.findOptionById(10L)).thenReturn(option); + when(perspectiveRepository.save(org.mockito.ArgumentMatchers.any(Perspective.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + CreatePerspectiveResponse response = perspectiveService.createPerspective( + 100L, 1L, new CreatePerspectiveRequest("두 번째 관점")); + + assertThat(response).isNotNull(); + org.mockito.Mockito.verify(perspectiveRepository).save(org.mockito.ArgumentMatchers.any(Perspective.class)); + } + + @Test + @DisplayName("내 관점 조회는 한 배틀에 남긴 여러 관점을 목록으로 반환한다") + void getMyPerspective_returns_multiple_perspectives_as_list() { + Battle battle = battle(100L); + User user = user(1L); + BattleOption option = option(10L, battle, BattleOptionLabel.A, "예술이다"); + + Perspective first = Perspective.builder().battle(battle).user(user).option(option).content("첫 번째").build(); + first.publish(); + ReflectionTestUtils.setField(first, "id", 1L); + + Perspective second = Perspective.builder().battle(battle).user(user).option(option).content("두 번째").build(); + second.publish(); + ReflectionTestUtils.setField(second, "id", 2L); + + when(battleService.findById(100L)).thenReturn(battle); + when(perspectiveRepository.findByBattleIdAndUserIdOrderByCreatedAtDesc(100L, 1L)) + .thenReturn(List.of(second, first)); + when(userQueryService.findSummaryById(1L)).thenReturn(new UserSummary("user-1", "nick", "OWL")); + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("character-url"); + + List responses = perspectiveService.getMyPerspective(100L, 1L); + + assertThat(responses).hasSize(2); + assertThat(responses).extracting(MyPerspectiveResponse::perspectiveId).containsExactly(2L, 1L); + } + private Battle battle(Long id) { Battle battle = Battle.builder() .title("battle")