From c9516155c8715996aa9bac1b03bc0f78fb836dbf Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 21 Apr 2026 14:44:38 +0900 Subject: [PATCH 01/43] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=B6=88=EA=B0=80=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Member soft delete 시 MemberProfile도 함께 soft delete 처리 - 닉네임 중복 체크 쿼리에 deletedAt IS NULL 조건 추가 (existsByNickname → existsByNicknameAndDeletedAtIsNull) - isAvailableNickname()도 동일하게 soft delete 고려하도록 수정 - MemberServiceImpl에 MemberProfileRepository 의존성 주입 --- .../valanse/repository/MemberProfileRepository.java | 2 +- .../MemberProfileService/MemberProfileServiceImpl.java | 8 ++++---- .../valanse/service/MemberService/MemberServiceImpl.java | 7 +++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java index 7117bf7..2873cfb 100644 --- a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java @@ -8,7 +8,7 @@ public interface MemberProfileRepository extends JpaRepository { Optional findByMemberId(Long id); - boolean existsByNickname(String nickname); + boolean existsByNicknameAndDeletedAtIsNull(String nickname); } diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java index 77a59d8..547a8f1 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java @@ -48,7 +48,7 @@ public void saveOrUpdateProfile(MemberProfileRequest dto) { // 닉네임이 변경되었을 때만 중복 체크 if (!profile.getNickname().equals(dto.nickname())) { - if (memberProfileRepository.existsByNickname(dto.nickname())) { + if (memberProfileRepository.existsByNicknameAndDeletedAtIsNull(dto.nickname())) { throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); } } @@ -57,8 +57,8 @@ public void saveOrUpdateProfile(MemberProfileRequest dto) { profile.update(dto.nickname(), dto.gender(), dto.age(), dto.mbtiIe(), dto.mbtiTf(), dto.mbti()); memberProfileRepository.save(profile); } else { - // ✅ 신규 생성: 무조건 중복 체크 - if (memberProfileRepository.existsByNickname(dto.nickname())) { + // ✅ 신규 생성: 무조건 중복 체크 (soft delete된 회원 닉네임 제외) + if (memberProfileRepository.existsByNicknameAndDeletedAtIsNull(dto.nickname())) { throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); } @@ -107,7 +107,7 @@ public MemberProfileResponse getProfile() { @Override public boolean isAvailableNickname(String nickname) { - return !memberProfileRepository.existsByNickname(nickname); + return !memberProfileRepository.existsByNicknameAndDeletedAtIsNull(nickname); } @Override diff --git a/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java index 8fedc4b..4515e60 100644 --- a/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java @@ -3,6 +3,7 @@ import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Member; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.MemberProfileRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; @@ -14,6 +15,7 @@ @RequiredArgsConstructor public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; + private final MemberProfileRepository memberProfileRepository; @Transactional(readOnly = true) @Override @@ -43,6 +45,11 @@ public Member deleteMemberById() { .orElseThrow(() -> new ApiException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND)); member.softDelete(); // Soft delete 처리 + + // MemberProfile도 함께 soft delete (닉네임 중복 방지) + memberProfileRepository.findByMemberId(userId) + .ifPresent(profile -> profile.softDelete()); + return memberRepository.save(member); // 삭제된 상태로 저장 } From 60b5bf297e1f0d39582e690792f6dca91071ba6d Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 21 Apr 2026 15:44:31 +0900 Subject: [PATCH 02/43] =?UTF-8?q?fix=20:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=88=98=EC=A0=95=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberProfileServiceImplTest.java | 407 ++++++------------ .../MemberService/MemberServiceImplTest.java | 53 ++- 2 files changed, 176 insertions(+), 284 deletions(-) diff --git a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java index ec54e7d..bb1ff7e 100644 --- a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java @@ -24,19 +24,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -/** - * Issue #110: MBTI 프로필 수정 검증 로직 테스트 - * - * 테스트 시나리오: - * 1. 닉네임 변경 없이 MBTI만 수정 - 중복 체크 없이 정상 저장 - * 2. MBTI 2~3글자만 입력 - "MBTI는 4자리여야 합니다" 에러 - * 3. IE만 선택하고 TF 미선택 - "MBTI를 모두 선택해주세요" 에러 - * 4. 닉네임 중복 (실제 중복) - "이미 사용 중인 닉네임입니다" 에러 - * 5. 신규 프로필 생성 시 닉네임 중복 - "이미 사용 중인 닉네임입니다" 에러 - * 6. 정상적인 MBTI 4글자 입력 - 정상 저장 - */ @ExtendWith(MockitoExtension.class) -class MemberProfileServiceImplTest_Issue110 { +class MemberProfileServiceImplTest { @InjectMocks private MemberProfileServiceImpl memberProfileService; @@ -51,16 +40,13 @@ class MemberProfileServiceImplTest_Issue110 { @BeforeEach void setupSecurityContext() { - // SecurityContext 설정 (userId = 1) Authentication authentication = mock(Authentication.class); - when(authentication.getName()).thenReturn("1"); + lenient().when(authentication.getName()).thenReturn("1"); SecurityContext securityContext = mock(SecurityContext.class); - when(securityContext.getAuthentication()).thenReturn(authentication); + lenient().when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); - // Member 객체 초기화 member = Member.builder() - .id(1L) .email("test@email.com") .nickname("테스터") .name("test") @@ -68,164 +54,69 @@ void setupSecurityContext() { .build(); } - @Test - @DisplayName("테스트 1: 닉네임 변경 없이 MBTI만 수정 - 중복 체크 없이 정상 저장") - void 닉네임_변경없이_MBTI만_수정_성공() { - // given - MemberProfile existingProfile = MemberProfile.builder() - .member(member) - .nickname("기존닉네임") - .gender(Gender.MALE) - .age(Age.TWENTY) - .mbtiIe(MbtiIe.E) - .mbtiTf(MbtiTf.T) - .mbti("ENTP") - .build(); - - MemberProfileRequest request = new MemberProfileRequest( - "기존닉네임", // 닉네임 변경 없음 - Gender.MALE, - Age.TWENTY, - MbtiIe.I, // IE 변경: E -> I - MbtiTf.F, // TF 변경: T -> F - "INFP" // MBTI 변경 - ); - - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.of(existingProfile)); - - // when - memberProfileService.saveOrUpdateProfile(request); - - // then - // 닉네임이 변경되지 않았으므로 중복 체크를 호출하지 않아야 함 - verify(memberProfileRepository, never()).existsByNickname(any()); - - // save는 1회 호출 - verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); - - // 프로필 업데이트 확인 - assertThat(existingProfile.getMbti()).isEqualTo("INFP"); - assertThat(existingProfile.getMbtiIe()).isEqualTo(MbtiIe.I); - assertThat(existingProfile.getMbtiTf()).isEqualTo(MbtiTf.F); - } + // ─────────────────────────────────────────────── + // [핵심] soft delete 회원 닉네임 재사용 시나리오 + // ─────────────────────────────────────────────── @Test - @DisplayName("테스트 2: MBTI 2~3글자만 입력 - 'MBTI는 4자리여야 합니다' 에러") - void MBTI_불완전_입력_실패() { + @DisplayName("[핵심] 탈퇴한 회원의 닉네임은 신규 가입자가 사용할 수 있어야 한다") + void 탈퇴회원_닉네임_신규회원이_재사용_가능() { // given MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", + "탈퇴한닉네임", // soft delete된 회원이 쓰던 닉네임 Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, - "ENT" // 3글자만 입력 + "ENTP" ); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)) + .thenReturn(Optional.empty()); // 신규 가입자 (프로필 없음) + // soft delete된 회원의 닉네임은 deletedAt IS NULL 조건에서 걸리지 않음 + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("탈퇴한닉네임")) + .thenReturn(false); - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); - - assertThat(exception.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); - - // 저장이 호출되지 않아야 함 - verify(memberProfileRepository, never()).save(any()); + // when & then: 예외 없이 정상 저장 + assertDoesNotThrow(() -> memberProfileService.saveOrUpdateProfile(request)); + verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); } @Test - @DisplayName("테스트 2-1: MBTI null 입력 - 'MBTI는 4자리여야 합니다' 에러") - void MBTI_null_입력_실패() { + @DisplayName("[핵심] 활성 회원이 사용 중인 닉네임은 중복으로 막혀야 한다") + void 활성회원_닉네임_중복_차단() { // given MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", + "활성회원닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, - null // null 입력 - ); - - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); - - assertThat(exception.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); - } - - @Test - @DisplayName("테스트 3: IE만 선택하고 TF 미선택 - 'MBTI를 모두 선택해주세요' 에러") - void MBTI_일부만_선택_실패() { - // given - MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", - Gender.MALE, - Age.TWENTY, - MbtiIe.E, // IE만 선택 - null, // TF 미선택 "ENTP" ); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)) + .thenReturn(Optional.empty()); + // 활성 회원이 이미 사용 중 + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("활성회원닉네임")) + .thenReturn(true); // when & then IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> memberProfileService.saveOrUpdateProfile(request) ); - - assertThat(exception.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); - - // 저장이 호출되지 않아야 함 + assertThat(exception.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); verify(memberProfileRepository, never()).save(any()); } @Test - @DisplayName("테스트 3-1: TF만 선택하고 IE 미선택 - 'MBTI를 모두 선택해주세요' 에러") - void MBTI_IE만_미선택_실패() { - // given - MemberProfileRequest request = new MemberProfileRequest( - "테스트닉네임", - Gender.MALE, - Age.TWENTY, - null, // IE 미선택 - MbtiTf.T, // TF만 선택 - "ENTP" - ); - - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); - - assertThat(exception.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); - } - - @Test - @DisplayName("테스트 4: 닉네임 변경 시 실제 중복 - '이미 사용 중인 닉네임입니다' 에러") - void 닉네임_변경시_중복_에러() { + @DisplayName("[핵심] 프로필 수정 시 탈퇴한 회원의 닉네임으로 변경 가능해야 한다") + void 프로필수정시_탈퇴회원_닉네임으로_변경_가능() { // given MemberProfile existingProfile = MemberProfile.builder() .member(member) @@ -238,7 +129,7 @@ void setupSecurityContext() { .build(); MemberProfileRequest request = new MemberProfileRequest( - "중복닉네임", // 다른 사람이 이미 사용 중 + "탈퇴한닉네임", // 탈퇴 회원이 쓰던 닉네임으로 변경 시도 Gender.MALE, Age.TWENTY, MbtiIe.E, @@ -246,194 +137,162 @@ void setupSecurityContext() { "ENTP" ); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); when(memberProfileRepository.findByMemberId(1L)) .thenReturn(Optional.of(existingProfile)); - when(memberProfileRepository.existsByNickname("중복닉네임")) - .thenReturn(true); // 중복됨 + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("탈퇴한닉네임")) + .thenReturn(false); // 탈퇴 회원 닉네임이므로 null 아닌 deletedAt 갖고 있음 // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); + assertDoesNotThrow(() -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(existingProfile.getNickname()).isEqualTo("탈퇴한닉네임"); + verify(memberProfileRepository, times(1)).save(existingProfile); + } - assertThat(exception.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); + @Test + @DisplayName("[핵심] isAvailableNickname은 soft delete된 회원 닉네임을 사용 가능으로 반환해야 한다") + void isAvailableNickname_탈퇴회원_닉네임은_사용가능() { + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("탈퇴한닉네임")) + .thenReturn(false); - // 중복 체크는 호출되어야 함 - verify(memberProfileRepository, times(1)).existsByNickname("중복닉네임"); + assertThat(memberProfileService.isAvailableNickname("탈퇴한닉네임")).isTrue(); + } - // 저장은 호출되지 않아야 함 - verify(memberProfileRepository, never()).save(any()); + @Test + @DisplayName("[핵심] isAvailableNickname은 활성 회원 닉네임을 사용 불가로 반환해야 한다") + void isAvailableNickname_활성회원_닉네임은_사용불가() { + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("활성닉네임")) + .thenReturn(true); + + assertThat(memberProfileService.isAvailableNickname("활성닉네임")).isFalse(); } + // ─────────────────────────────────────────────── + // 기존 시나리오 + // ─────────────────────────────────────────────── + @Test - @DisplayName("테스트 5: 신규 프로필 생성 시 닉네임 중복 - '이미 사용 중인 닉네임입니다' 에러") - void 신규_프로필_닉네임_중복_에러() { - // given + @DisplayName("닉네임 변경 없이 MBTI만 수정 - 중복 체크 없이 정상 저장") + void 닉네임_변경없이_MBTI만_수정_성공() { + MemberProfile existingProfile = MemberProfile.builder() + .member(member).nickname("기존닉네임") + .gender(Gender.MALE).age(Age.TWENTY) + .mbtiIe(MbtiIe.E).mbtiTf(MbtiTf.T).mbti("ENTP") + .build(); + MemberProfileRequest request = new MemberProfileRequest( - "중복닉네임", - Gender.MALE, - Age.TWENTY, - MbtiIe.E, - MbtiTf.T, - "ENTP" + "기존닉네임", Gender.MALE, Age.TWENTY, MbtiIe.I, MbtiTf.F, "INFP" ); - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.empty()); // 기존 프로필 없음 (신규) - when(memberProfileRepository.existsByNickname("중복닉네임")) - .thenReturn(true); // 중복됨 + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(existingProfile)); - // when & then - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> memberProfileService.saveOrUpdateProfile(request) - ); + memberProfileService.saveOrUpdateProfile(request); - assertThat(exception.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); + verify(memberProfileRepository, never()).existsByNicknameAndDeletedAtIsNull(any()); + verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); + assertThat(existingProfile.getMbti()).isEqualTo("INFP"); + } - // 신규 생성 시에도 중복 체크 호출 - verify(memberProfileRepository, times(1)).existsByNickname("중복닉네임"); + @Test + @DisplayName("MBTI 3글자 입력 - 'MBTI는 4자리여야 합니다' 에러") + void MBTI_불완전_입력_실패() { + MemberProfileRequest request = new MemberProfileRequest( + "테스트닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, "ENT" + ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - // 저장은 호출되지 않아야 함 + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); verify(memberProfileRepository, never()).save(any()); } @Test - @DisplayName("테스트 6: 정상적인 MBTI 4글자 입력 - 정상 저장") - void 정상_프로필_저장_성공() { - // given + @DisplayName("MBTI null 입력 - 'MBTI는 4자리여야 합니다' 에러") + void MBTI_null_입력_실패() { MemberProfileRequest request = new MemberProfileRequest( - "새로운닉네임", - Gender.FEMALE, - Age.THIRTY, - MbtiIe.I, - MbtiTf.F, - "INFP" // 정상적인 4글자 + "테스트닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, null ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - MemberProfile newProfile = MemberProfile.builder() - .member(member) - .nickname(request.nickname()) - .gender(request.gender()) - .age(request.age()) - .mbtiIe(request.mbtiIe()) - .mbtiTf(request.mbtiTf()) - .mbti(request.mbti()) - .build(); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI는 4자리여야 합니다 (예: ENFP)"); + } - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.empty()); // 신규 생성 - when(memberProfileRepository.existsByNickname("새로운닉네임")) - .thenReturn(false); // 중복 없음 - when(memberProfileRepository.save(any(MemberProfile.class))) - .thenReturn(newProfile); + @Test + @DisplayName("IE만 선택하고 TF 미선택 - 'MBTI를 모두 선택해주세요' 에러") + void MBTI_TF_미선택_실패() { + MemberProfileRequest request = new MemberProfileRequest( + "테스트닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, null, "ENTP" + ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - // when - memberProfileService.saveOrUpdateProfile(request); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); + verify(memberProfileRepository, never()).save(any()); + } - // then - // 중복 체크 호출 - verify(memberProfileRepository, times(1)).existsByNickname("새로운닉네임"); + @Test + @DisplayName("TF만 선택하고 IE 미선택 - 'MBTI를 모두 선택해주세요' 에러") + void MBTI_IE_미선택_실패() { + MemberProfileRequest request = new MemberProfileRequest( + "테스트닉네임", Gender.MALE, Age.TWENTY, null, MbtiTf.T, "ENTP" + ); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); - // 저장 1회 호출 - verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("MBTI를 모두 선택해주세요"); } @Test - @DisplayName("테스트 7: 닉네임 동일, MBTI 정상 변경 - 정상 저장") - void 닉네임_동일_MBTI_변경_성공() { - // given + @DisplayName("닉네임 변경 시 활성 회원과 중복 - '이미 사용 중인 닉네임입니다' 에러") + void 닉네임_변경시_활성회원_중복_에러() { MemberProfile existingProfile = MemberProfile.builder() - .member(member) - .nickname("기존닉네임") - .gender(Gender.MALE) - .age(Age.TWENTY) - .mbtiIe(MbtiIe.E) - .mbtiTf(MbtiTf.T) - .mbti("ENTP") + .member(member).nickname("기존닉네임") + .gender(Gender.MALE).age(Age.TWENTY) + .mbtiIe(MbtiIe.E).mbtiTf(MbtiTf.T).mbti("ENTP") .build(); MemberProfileRequest request = new MemberProfileRequest( - "기존닉네임", // 닉네임 변경 없음 - Gender.MALE, - Age.TWENTY, - MbtiIe.I, - MbtiTf.F, - "INFP" // MBTI만 변경 + "중복닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, "ENTP" ); - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.of(existingProfile)); - - // when - memberProfileService.saveOrUpdateProfile(request); + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(existingProfile)); + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("중복닉네임")).thenReturn(true); - // then - // 닉네임 동일하므로 중복 체크 호출 안됨 - verify(memberProfileRepository, never()).existsByNickname(any()); - - // 저장은 1회 호출 - verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); - - // MBTI가 변경되었는지 확인 - assertThat(existingProfile.getMbti()).isEqualTo("INFP"); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> memberProfileService.saveOrUpdateProfile(request)); + assertThat(ex.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); + verify(memberProfileRepository, never()).save(any()); } @Test - @DisplayName("테스트 8: 닉네임 변경하면서 중복 없음 - 정상 저장") + @DisplayName("닉네임 변경 - 중복 없음 - 정상 저장") void 닉네임_변경_중복없음_성공() { - // given MemberProfile existingProfile = MemberProfile.builder() - .member(member) - .nickname("기존닉네임") - .gender(Gender.MALE) - .age(Age.TWENTY) - .mbtiIe(MbtiIe.E) - .mbtiTf(MbtiTf.T) - .mbti("ENTP") + .member(member).nickname("기존닉네임") + .gender(Gender.MALE).age(Age.TWENTY) + .mbtiIe(MbtiIe.E).mbtiTf(MbtiTf.T).mbti("ENTP") .build(); MemberProfileRequest request = new MemberProfileRequest( - "새닉네임", // 닉네임 변경 - Gender.MALE, - Age.TWENTY, - MbtiIe.E, - MbtiTf.T, - "ENTP" + "새닉네임", Gender.MALE, Age.TWENTY, MbtiIe.E, MbtiTf.T, "ENTP" ); - // stub - when(memberRepository.findByIdAndDeletedAtIsNull(1L)) - .thenReturn(Optional.of(member)); - when(memberProfileRepository.findByMemberId(1L)) - .thenReturn(Optional.of(existingProfile)); - when(memberProfileRepository.existsByNickname("새닉네임")) - .thenReturn(false); // 중복 없음 + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(existingProfile)); + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("새닉네임")).thenReturn(false); - // when memberProfileService.saveOrUpdateProfile(request); - // then - // 닉네임이 변경되었으므로 중복 체크 호출됨 - verify(memberProfileRepository, times(1)).existsByNickname("새닉네임"); - - // 저장은 1회 호출 + verify(memberProfileRepository, times(1)).existsByNicknameAndDeletedAtIsNull("새닉네임"); verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); - - // 닉네임이 변경되었는지 확인 assertThat(existingProfile.getNickname()).isEqualTo("새닉네임"); } -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java index 6756426..8c42706 100644 --- a/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberService/MemberServiceImplTest.java @@ -2,9 +2,12 @@ import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -20,7 +23,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -// Repository(db) 에 의존적인 메서드는 테스트 하지 않는다. @ExtendWith(MockitoExtension.class) class MemberServiceImplTest { @@ -30,42 +32,73 @@ class MemberServiceImplTest { @Mock private MemberRepository memberRepository; + @Mock + private MemberProfileRepository memberProfileRepository; + @BeforeEach void setupSecurityContext() { Authentication authentication = mock(Authentication.class); - when(authentication.getName()).thenReturn("1"); // userId=1 가정 + when(authentication.getName()).thenReturn("1"); SecurityContext securityContext = mock(SecurityContext.class); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); } @Test - void 멤버삭제_test() { + @DisplayName("회원 탈퇴 시 Member와 MemberProfile 모두 soft delete 처리") + void 멤버삭제_MemberProfile도_함께_softDelete() { // given Member member = new Member(); + MemberProfile profile = new MemberProfile(); - when(memberRepository.findByIdAndDeletedAtIsNull(any())) + when(memberRepository.findByIdAndDeletedAtIsNull(1L)) + .thenReturn(Optional.of(member)); + when(memberRepository.save(any(Member.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(memberProfileRepository.findByMemberId(1L)) + .thenReturn(Optional.of(profile)); + + // when + Member deletedMember = memberService.deleteMemberById(); + + // then + assertNotNull(deletedMember.getDeletedAt(), "Member의 deletedAt이 설정되어야 한다"); + assertNotNull(profile.getDeletedAt(), "MemberProfile의 deletedAt도 함께 설정되어야 한다"); + verify(memberRepository, times(1)).save(member); + verify(memberProfileRepository, times(1)).findByMemberId(1L); + } + + @Test + @DisplayName("MemberProfile이 없는 회원도 탈퇴 정상 처리") + void 멤버삭제_프로필없어도_정상처리() { + // given + Member member = new Member(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(member)); when(memberRepository.save(any(Member.class))) .thenAnswer(invocation -> invocation.getArgument(0)); + when(memberProfileRepository.findByMemberId(1L)) + .thenReturn(Optional.empty()); // 프로필 없음 // when Member deletedMember = memberService.deleteMemberById(); - // then: member 객체 deletedAt 속성 검증 및 member 저장 1회 검증 - assertNotNull(deletedMember.getDeletedAt()); - verify(memberRepository).save(member); + // then + assertNotNull(deletedMember.getDeletedAt(), "Member의 deletedAt이 설정되어야 한다"); + verify(memberRepository, times(1)).save(member); } @Test - void 멤버삭제실패_test() { + @DisplayName("존재하지 않는 회원 탈퇴 시 ApiException 발생") + void 멤버삭제실패_존재하지않는_회원() { // given when(memberRepository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.empty()); - // when & then : Exception 검증 + // when & then ApiException exception = assertThrows(ApiException.class, () -> memberService.deleteMemberById()); assertEquals("사용자를 찾을 수 없습니다", exception.getMessage()); } -} \ No newline at end of file +} From bb690734a706ef1c9179972961fe97fde1545d32 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 21 Apr 2026 21:39:44 +0900 Subject: [PATCH 03/43] =?UTF-8?q?feat:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PointType enum 추가 (SIGN_UP, POST_CREATE, COMMENT_CREATE, POST_VOTED, HOT_ISSUE) - PointHistory 엔티티 및 Repository 추가 - PointService / PointServiceImpl 추가 (가입 100pt, 게시물 50pt, 댓글 10pt, 투표참여 5pt) - MemberProfile에 point 필드 추가 (기본값 0) 및 addPoint() 추가 - 프로필 최초 생성 시 회원가입 포인트 100pt 지급 - 게시물 작성 시 작성자에게 50pt 지급 - 댓글 작성(부모) 시 작성자에게 10pt 지급 - 투표 참여 시 게시물 작성자에게 5pt 지급 (자기 게시물 제외) - 포인트 랭킹 API 추가 (GET /point/ranking, 공개) --- .../valanse/common/config/SecurityConfig.java | 3 + .../valanse/controller/PointController.java | 24 ++++++++ .../valanse/valanse/domain/MemberProfile.java | 6 ++ .../valanse/valanse/domain/PointHistory.java | 38 +++++++++++++ .../valanse/domain/enums/PointType.java | 9 +++ .../MemberPointRankingResponse.java | 8 +++ .../repository/MemberProfileRepository.java | 3 + .../repository/PointHistoryRepository.java | 10 ++++ .../CommentService/CommentServiceImpl.java | 12 +++- .../MemberProfileService.java | 11 +++- .../MemberProfileServiceImpl.java | 23 ++++++++ .../MemberService/MemberServiceImpl.java | 7 +++ .../service/PointService/PointService.java | 7 +++ .../PointService/PointServiceImpl.java | 56 +++++++++++++++++++ .../service/VoteService/VoteServiceImpl.java | 26 ++++++--- 15 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/valanse/valanse/controller/PointController.java create mode 100644 src/main/java/com/valanse/valanse/domain/PointHistory.java create mode 100644 src/main/java/com/valanse/valanse/domain/enums/PointType.java create mode 100644 src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java create mode 100644 src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java create mode 100644 src/main/java/com/valanse/valanse/service/PointService/PointService.java create mode 100644 src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index 34a2e8a..a804ebd 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -80,6 +80,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 회원 관련 .requestMatchers("/member/**").authenticated() + // 포인트 랭킹 (공개) + .requestMatchers(HttpMethod.GET, "/point/ranking").permitAll() + // 나머지 모든 요청 .anyRequest().authenticated() ) diff --git a/src/main/java/com/valanse/valanse/controller/PointController.java b/src/main/java/com/valanse/valanse/controller/PointController.java new file mode 100644 index 0000000..b6319c5 --- /dev/null +++ b/src/main/java/com/valanse/valanse/controller/PointController.java @@ -0,0 +1,24 @@ +package com.valanse.valanse.controller; + +import com.valanse.valanse.dto.MemberProfile.MemberPointRankingResponse; +import com.valanse.valanse.service.MemberProfileService.MemberProfileService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/point") +@RequiredArgsConstructor +public class PointController { + + private final MemberProfileService memberProfileService; + + @GetMapping("/ranking") + public ResponseEntity> getPointRanking() { + return ResponseEntity.ok(memberProfileService.getPointRanking()); + } +} diff --git a/src/main/java/com/valanse/valanse/domain/MemberProfile.java b/src/main/java/com/valanse/valanse/domain/MemberProfile.java index 068a922..ceb5548 100644 --- a/src/main/java/com/valanse/valanse/domain/MemberProfile.java +++ b/src/main/java/com/valanse/valanse/domain/MemberProfile.java @@ -43,6 +43,9 @@ public class MemberProfile extends BaseEntity { private String mbti; + @Builder.Default + private long point = 0L; + public void update(String nickname, Gender gender, Age age, MbtiIe mbtiIe, MbtiTf mbtiTf, String mbti) { this.nickname = nickname; this.gender = gender; @@ -52,4 +55,7 @@ public void update(String nickname, Gender gender, Age age, MbtiIe mbtiIe, MbtiT this.mbti = mbti; } + public void addPoint(long amount) { + this.point += amount; + } } diff --git a/src/main/java/com/valanse/valanse/domain/PointHistory.java b/src/main/java/com/valanse/valanse/domain/PointHistory.java new file mode 100644 index 0000000..90e46a0 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/PointHistory.java @@ -0,0 +1,38 @@ +package com.valanse.valanse.domain; + +import com.valanse.valanse.domain.enums.PointType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PointHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + private Long amount; + + @Enumerated(EnumType.STRING) + private PointType type; + + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/valanse/valanse/domain/enums/PointType.java b/src/main/java/com/valanse/valanse/domain/enums/PointType.java new file mode 100644 index 0000000..37a18e1 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/enums/PointType.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.domain.enums; + +public enum PointType { + COMMENT_CREATE, + POST_CREATE, + POST_VOTED, + HOT_ISSUE, + SIGN_UP +} diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java new file mode 100644 index 0000000..b276bbc --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java @@ -0,0 +1,8 @@ +package com.valanse.valanse.dto.MemberProfile; + +public record MemberPointRankingResponse( + String nickname, + long point, + int rank +) { +} diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java index 2873cfb..ce55c8b 100644 --- a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java @@ -3,6 +3,7 @@ import com.valanse.valanse.domain.MemberProfile; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface MemberProfileRepository extends JpaRepository { @@ -11,4 +12,6 @@ public interface MemberProfileRepository extends JpaRepository findAllByDeletedAtIsNullOrderByPointDesc(); + } diff --git a/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java new file mode 100644 index 0000000..12bbf04 --- /dev/null +++ b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java @@ -0,0 +1,10 @@ +package com.valanse.valanse.repository; + +import com.valanse.valanse.domain.PointHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PointHistoryRepository extends JpaRepository { + List findByMemberId(Long memberId); +} diff --git a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java index fc8773b..a403f8d 100644 --- a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java @@ -4,10 +4,12 @@ import com.querydsl.core.types.dsl.NumberTemplate; import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.*; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.VoteLabel; import com.valanse.valanse.dto.Comment.*; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -34,6 +36,7 @@ public class CommentServiceImpl implements CommentService { private final CommentGroupRepository commentGroupRepository; private final CommentRepository commentRepository; private final MemberProfileRepository memberProfileRepository; + private final PointService pointService; @Override public void deleteMyComment(Member member, Long commentId) { @@ -137,7 +140,14 @@ public Long createComment(Long voteId, Long userId, CommentPostRequest request) commentGroupRepository.save(commentGroup); } - return commentRepository.save(comment).getId(); + Long savedCommentId = commentRepository.save(comment).getId(); + + // 댓글 작성 포인트 지급 (부모 댓글일 때만) + if (request.getParentId() == null) { + pointService.givePoint(userId, PointType.COMMENT_CREATE); + } + + return savedCommentId; } @Override diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java index 1da8597..79ae35e 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java @@ -2,16 +2,21 @@ import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; +import com.valanse.valanse.dto.MemberProfile.MemberPointRankingResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; +import java.util.List; + public interface MemberProfileService { void saveOrUpdateProfile(MemberProfileRequest dto); MemberProfileResponse getProfile(); MemberMyPageResponse getMyProfile(); - boolean isAvailableNickname(String nickname); // 중복 아님 → true - boolean isMeaningfulNickname(String nickname); // 무의미하지 않음 → true - boolean isCleanNickname(String nickname); // 비속어 아님 → true + boolean isAvailableNickname(String nickname); + boolean isMeaningfulNickname(String nickname); + boolean isCleanNickname(String nickname); + + List getPointRanking(); } diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java index 547a8f1..4d8ecc0 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java @@ -2,18 +2,23 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; +import com.valanse.valanse.dto.MemberProfile.MemberPointRankingResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; @Service @RequiredArgsConstructor @@ -22,6 +27,7 @@ public class MemberProfileServiceImpl implements MemberProfileService { private final MemberRepository memberRepository; private final MemberProfileRepository memberProfileRepository; + private final PointService pointService; @Override public void saveOrUpdateProfile(MemberProfileRequest dto) { @@ -74,6 +80,9 @@ public void saveOrUpdateProfile(MemberProfileRequest dto) { .build(); memberProfileRepository.save(newProfile); + + // 신규 프로필 생성 시 회원가입 포인트 지급 + pointService.givePoint(userId, PointType.SIGN_UP); } } @@ -214,4 +223,18 @@ public MemberMyPageResponse getMyProfile() { return new MemberMyPageResponse(info); } + + @Transactional(readOnly = true) + @Override + public List getPointRanking() { + AtomicInteger rank = new AtomicInteger(1); + return memberProfileRepository.findAllByDeletedAtIsNullOrderByPointDesc() + .stream() + .map(profile -> new MemberPointRankingResponse( + profile.getNickname(), + profile.getPoint(), + rank.getAndIncrement() + )) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java index 4515e60..c7effea 100644 --- a/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberService/MemberServiceImpl.java @@ -2,8 +2,10 @@ import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; @@ -16,6 +18,7 @@ public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; private final MemberProfileRepository memberProfileRepository; + private final PointService pointService; @Transactional(readOnly = true) @Override @@ -35,6 +38,10 @@ public Member createOauth(String socialId, String email, String name, String pro .kakaoRefreshToken(refresh_token) .build(); memberRepository.save(member); + + // 회원가입 포인트 지급 (프로필 생성 후 지급되므로 여기선 기록만 남김) + // 실제 포인트는 프로필 저장 시점에 지급 (MemberProfileServiceImpl 참고) + return member; } diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointService.java b/src/main/java/com/valanse/valanse/service/PointService/PointService.java new file mode 100644 index 0000000..2f46dcd --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/PointService/PointService.java @@ -0,0 +1,7 @@ +package com.valanse.valanse.service.PointService; + +import com.valanse.valanse.domain.enums.PointType; + +public interface PointService { + void givePoint(Long memberId, PointType type); +} diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java new file mode 100644 index 0000000..f573054 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java @@ -0,0 +1,56 @@ +package com.valanse.valanse.service.PointService; + +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.PointHistory; +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.PointHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class PointServiceImpl implements PointService { + + private final MemberRepository memberRepository; + private final MemberProfileRepository memberProfileRepository; + private final PointHistoryRepository pointHistoryRepository; + + // 포인트 정책 + private static final long SIGN_UP_POINT = 100L; + private static final long POST_CREATE_POINT = 50L; + private static final long COMMENT_CREATE_POINT = 10L; + private static final long POST_VOTED_POINT = 5L; + private static final long HOT_ISSUE_POINT = 200L; + + @Override + public void givePoint(Long memberId, PointType type) { + Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + MemberProfile profile = memberProfileRepository.findByMemberId(memberId) + .orElseThrow(() -> new IllegalArgumentException("프로필을 찾을 수 없습니다.")); + + long amount = switch (type) { + case SIGN_UP -> SIGN_UP_POINT; + case POST_CREATE -> POST_CREATE_POINT; + case COMMENT_CREATE -> COMMENT_CREATE_POINT; + case POST_VOTED -> POST_VOTED_POINT; + case HOT_ISSUE -> HOT_ISSUE_POINT; + }; + + profile.addPoint(amount); + memberProfileRepository.save(profile); + + PointHistory history = PointHistory.builder() + .member(member) + .amount(amount) + .type(type) + .build(); + pointHistoryRepository.save(history); + } +} diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 7c84bc4..6b3dad4 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -3,17 +3,19 @@ import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.*; import com.valanse.valanse.domain.enums.PinType; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.VoteCategory; import com.valanse.valanse.domain.enums.VoteLabel; import com.valanse.valanse.domain.mapping.MemberVoteOption; import com.valanse.valanse.dto.Vote.*; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; // import 추가 +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; // 기존 코드에 있었으므로 유지 import java.util.List; // 기존 코드에 있었으므로 유지 @@ -26,11 +28,12 @@ public class VoteServiceImpl implements VoteService { private final VoteRepository voteRepository; - private final MemberProfileRepository memberProfileRepository; // MemberProfile 정보 조회를 위해 필요 - private final MemberRepository memberRepository; // processVote 메서드에서 Member 조회를 위해 추가 - private final VoteOptionRepository voteOptionRepository; // processVote 메서드에서 VoteOption 조회를 위해 추가 - private final MemberVoteOptionRepository memberVoteOptionRepository; // processVote 메서드에서 MemberVoteOption 조회를 위해 추가 + private final MemberProfileRepository memberProfileRepository; + private final MemberRepository memberRepository; + private final VoteOptionRepository voteOptionRepository; + private final MemberVoteOptionRepository memberVoteOptionRepository; private final CommentGroupRepository commentGroupRepository; + private final PointService pointService; //작은 민지가 구현한 것 @Override @@ -229,19 +232,21 @@ public VoteCancleResponseDto processVote(Long userId, Long voteId, Long voteOpti } } else { // 4. 이전에 투표한 기록이 없는 경우: 새로운 투표 기록 - // 새로운 member_vote_option 기록 생성 및 저장 MemberVoteOption newMemberVoteOption = MemberVoteOption.builder() .member(member) .vote(vote) .voteOption(newVoteOption) .build(); memberVoteOptionRepository.save(newMemberVoteOption); - // 선택지 투표 수 증가 newVoteOption.setVoteCount(newVoteOption.getVoteCount() + 1); - // 전체 투표 수 증가 vote.setTotalVoteCount(vote.getTotalVoteCount() + 1); - isVoted = true; // 새로운 옵션에 투표했으므로 true + // 게시물 작성자에게 투표 참여 포인트 지급 + if (vote.getMember() != null && !vote.getMember().getId().equals(userId)) { + pointService.givePoint(vote.getMember().getId(), PointType.POST_VOTED); + } + + isVoted = true; updatedTotalVoteCount = vote.getTotalVoteCount(); updatedVoteOptionCount = newVoteOption.getVoteCount(); } @@ -386,6 +391,9 @@ public Long createVote(Long userId, VoteCreateRequest request) { commentGroupRepository.save(commentGroup); // CommentGroup 저장 + // 게시물 작성 포인트 지급 + pointService.givePoint(userId, PointType.POST_CREATE); + return savedVote.getId(); // 저장된 투표의 ID를 반환 } From 859daf28f0387350944b46e264f3628cd9ce8c46 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 22 Apr 2026 13:10:07 +0900 Subject: [PATCH 04/43] =?UTF-8?q?feat=20:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/controller/PointController.java | 24 ------------------- .../MemberPointRankingResponse.java | 8 ------- .../MemberProfileService.java | 3 --- .../MemberProfileServiceImpl.java | 16 ------------- 4 files changed, 51 deletions(-) delete mode 100644 src/main/java/com/valanse/valanse/controller/PointController.java delete mode 100644 src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java diff --git a/src/main/java/com/valanse/valanse/controller/PointController.java b/src/main/java/com/valanse/valanse/controller/PointController.java deleted file mode 100644 index b6319c5..0000000 --- a/src/main/java/com/valanse/valanse/controller/PointController.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.valanse.valanse.controller; - -import com.valanse.valanse.dto.MemberProfile.MemberPointRankingResponse; -import com.valanse.valanse.service.MemberProfileService.MemberProfileService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@RestController -@RequestMapping("/point") -@RequiredArgsConstructor -public class PointController { - - private final MemberProfileService memberProfileService; - - @GetMapping("/ranking") - public ResponseEntity> getPointRanking() { - return ResponseEntity.ok(memberProfileService.getPointRanking()); - } -} diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java deleted file mode 100644 index b276bbc..0000000 --- a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberPointRankingResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.valanse.valanse.dto.MemberProfile; - -public record MemberPointRankingResponse( - String nickname, - long point, - int rank -) { -} diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java index 79ae35e..bf574c7 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileService.java @@ -2,7 +2,6 @@ import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; -import com.valanse.valanse.dto.MemberProfile.MemberPointRankingResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; @@ -16,7 +15,5 @@ public interface MemberProfileService { boolean isAvailableNickname(String nickname); boolean isMeaningfulNickname(String nickname); boolean isCleanNickname(String nickname); - - List getPointRanking(); } diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java index 4d8ecc0..a9c9108 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java @@ -4,7 +4,6 @@ import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; -import com.valanse.valanse.dto.MemberProfile.MemberPointRankingResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; import com.valanse.valanse.repository.MemberProfileRepository; @@ -15,10 +14,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; @Service @RequiredArgsConstructor @@ -224,17 +221,4 @@ public MemberMyPageResponse getMyProfile() { return new MemberMyPageResponse(info); } - @Transactional(readOnly = true) - @Override - public List getPointRanking() { - AtomicInteger rank = new AtomicInteger(1); - return memberProfileRepository.findAllByDeletedAtIsNullOrderByPointDesc() - .stream() - .map(profile -> new MemberPointRankingResponse( - profile.getNickname(), - profile.getPoint(), - rank.getAndIncrement() - )) - .toList(); - } } \ No newline at end of file From 7f6f7b41b0e5c317ebb931efd01414bff4cb8512 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 22 Apr 2026 13:29:35 +0900 Subject: [PATCH 05/43] =?UTF-8?q?feat=20:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EB=B0=A9=EC=8B=9D=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/PointHistoryRepository.java | 11 +++++ .../PointService/PointServiceImpl.java | 47 +++++++++++++------ .../CommentServiceImplTest.java | 5 +- .../MemberProfileServiceImplTest.java | 3 ++ .../VoteService/VoteServiceImplTest.java | 5 +- 5 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java index 12bbf04..10cce04 100644 --- a/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java +++ b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java @@ -1,10 +1,21 @@ package com.valanse.valanse.repository; import com.valanse.valanse.domain.PointHistory; +import com.valanse.valanse.domain.enums.PointType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; public interface PointHistoryRepository extends JpaRepository { List findByMemberId(Long memberId); + + @Query("SELECT COUNT(p) FROM PointHistory p WHERE p.member.id = :memberId AND p.type = :type AND p.createdAt >= :from") + long countByMemberIdAndTypeAndCreatedAtAfter( + @Param("memberId") Long memberId, + @Param("type") PointType type, + @Param("from") LocalDateTime from + ); } diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java index f573054..617e1b2 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java @@ -11,6 +11,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @Transactional @@ -21,11 +24,14 @@ public class PointServiceImpl implements PointService { private final PointHistoryRepository pointHistoryRepository; // 포인트 정책 - private static final long SIGN_UP_POINT = 100L; - private static final long POST_CREATE_POINT = 50L; - private static final long COMMENT_CREATE_POINT = 10L; - private static final long POST_VOTED_POINT = 5L; - private static final long HOT_ISSUE_POINT = 200L; + private static final long SIGN_UP_POINT = 40L; + private static final long POST_CREATE_POINT = 5L; + private static final long COMMENT_CREATE_POINT = 1L; + private static final long POST_VOTED_POINT = 1L; + private static final long HOT_ISSUE_POINT = 50L; + + // 댓글 포인트 일일 최대 획득 횟수 + private static final long COMMENT_DAILY_LIMIT = 3L; @Override public void givePoint(Long memberId, PointType type) { @@ -38,19 +44,32 @@ public void givePoint(Long memberId, PointType type) { long amount = switch (type) { case SIGN_UP -> SIGN_UP_POINT; case POST_CREATE -> POST_CREATE_POINT; - case COMMENT_CREATE -> COMMENT_CREATE_POINT; + case COMMENT_CREATE -> { + // 오늘 자정부터 현재까지 댓글 포인트 획득 횟수 체크 + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + long todayCount = pointHistoryRepository.countByMemberIdAndTypeAndCreatedAtAfter( + memberId, PointType.COMMENT_CREATE, todayStart + ); + if (todayCount >= COMMENT_DAILY_LIMIT) { + yield 0L; // 일일 제한 초과 시 0 포인트 지급 + } + yield COMMENT_CREATE_POINT; + } case POST_VOTED -> POST_VOTED_POINT; case HOT_ISSUE -> HOT_ISSUE_POINT; }; - profile.addPoint(amount); - memberProfileRepository.save(profile); + // 포인트가 0보다 클 때만 포인트 지급 및 히스토리 저장 + if (amount > 0) { + profile.addPoint(amount); + memberProfileRepository.save(profile); - PointHistory history = PointHistory.builder() - .member(member) - .amount(amount) - .type(type) - .build(); - pointHistoryRepository.save(history); + PointHistory history = PointHistory.builder() + .member(member) + .amount(amount) + .type(type) + .build(); + pointHistoryRepository.save(history); + } } } diff --git a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java index 4fbeddc..3533255 100644 --- a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java @@ -5,6 +5,7 @@ import com.valanse.valanse.dto.Comment.BestCommentResponseDto; import com.valanse.valanse.dto.Comment.CommentPostRequest; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,6 +36,8 @@ class CommentServiceImplTest { private CommentGroupRepository commentGroupRepository; @Mock private MemberProfileRepository memberProfileRepository; + @Mock + private PointService pointService; private Member member; private Vote vote; @@ -432,4 +435,4 @@ void getBestComment_actualCount_test() { assertThat(response.totalCommentCount()).isEqualTo(7); assertThat(response.content()).isEqualTo("인기 댓글"); } -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java index bb1ff7e..e771987 100644 --- a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java @@ -6,6 +6,7 @@ import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,6 +36,8 @@ class MemberProfileServiceImplTest { @Mock private MemberRepository memberRepository; + @Mock + private PointService pointService; private Member member; diff --git a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java index a28d9a4..e111f4c 100644 --- a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java @@ -11,6 +11,7 @@ import com.valanse.valanse.dto.Vote.VoteCancleResponseDto; import com.valanse.valanse.dto.Vote.VoteCreateRequest; import com.valanse.valanse.repository.*; +import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -44,6 +45,8 @@ class VoteServiceImplTest { private CommentGroupRepository commentGroupRepository; @Mock private VoteOptionRepository voteOptionRepository; + @Mock + private PointService pointService; @Test @DisplayName("투표 제목은 너무 길거나 너무 짧으면 안된다.") @@ -310,4 +313,4 @@ class VoteServiceImplTest { -} \ No newline at end of file +} From 0f544590e88c48ca4dbcd740bc7cb7cc6f2d3311 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 22 Apr 2026 13:39:29 +0900 Subject: [PATCH 06/43] =?UTF-8?q?feat=20:=20=ED=9A=8C=EC=9B=90=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberProfile/MemberMyPageResponse.java | 5 +- .../MemberProfile/MemberProfileResponse.java | 3 +- .../MemberProfileServiceImpl.java | 8 ++- .../MemberProfileServiceImplTest.java | 56 +++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java index d574764..6d8effc 100644 --- a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java +++ b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java @@ -10,8 +10,7 @@ public record MyPageInfo( String nickname, String gender, String age, - String mbti + String mbti, + long point ){} } - - diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java index a524ff6..947d044 100644 --- a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java +++ b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java @@ -12,6 +12,7 @@ public record Info( String mbtiIe, String mbtiTf, String mbti, - Role role + Role role, + long point ) {} } diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java index a9c9108..9103ea2 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java @@ -105,7 +105,8 @@ public MemberProfileResponse getProfile() { profile.getMbtiIe() != null ? profile.getMbtiIe().name() : null, profile.getMbtiTf() != null ? profile.getMbtiTf().name() : null, profile.getMbti() != null ? profile.getMbti() : null, - member.getRole() != null ? member.getRole() : null + member.getRole() != null ? member.getRole() : null, + profile.getPoint() ); return new MemberProfileResponse(info); @@ -215,10 +216,11 @@ public MemberMyPageResponse getMyProfile() { profile.getNickname(), profile.getGender() != null ? profile.getGender().name() : null, profile.getAge() != null ? profile.getAge().name() : null, - profile.getMbti() != null ? profile.getMbti() : null + profile.getMbti() != null ? profile.getMbti() : null, + profile.getPoint() ); return new MemberMyPageResponse(info); } -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java index e771987..fbe6107 100644 --- a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java @@ -298,4 +298,60 @@ void setupSecurityContext() { verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); assertThat(existingProfile.getNickname()).isEqualTo("새닉네임"); } + + @Test + @DisplayName("getProfile() 메서드가 포인트 정보를 포함해서 반환하는지 확인") + void getProfile_포인트_정보_포함_확인() { + // given + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickname("테스트닉네임") + .gender(Gender.MALE) + .age(Age.TWENTY) + .mbtiIe(MbtiIe.E) + .mbtiTf(MbtiTf.T) + .mbti("ENTP") + .point(100L) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + + // when + var response = memberProfileService.getProfile(); + + // then + assertThat(response).isNotNull(); + assertThat(response.profile()).isNotNull(); + assertThat(response.profile().point()).isEqualTo(100L); + assertThat(response.profile().nickname()).isEqualTo("테스트닉네임"); + } + + @Test + @DisplayName("getMyProfile() 메서드가 포인트 정보를 포함해서 반환하는지 확인") + void getMyProfile_포인트_정보_포함_확인() { + // given + MemberProfile profile = MemberProfile.builder() + .member(member) + .nickname("테스트닉네임") + .gender(Gender.MALE) + .age(Age.TWENTY) + .mbtiIe(MbtiIe.E) + .mbtiTf(MbtiTf.T) + .mbti("ENTP") + .point(150L) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + + // when + var response = memberProfileService.getMyProfile(); + + // then + assertThat(response).isNotNull(); + assertThat(response.profile()).isNotNull(); + assertThat(response.profile().point()).isEqualTo(150L); + assertThat(response.profile().nickname()).isEqualTo("테스트닉네임"); + } } From 3d1c752615c5393611a52a6ae5215ab49fd13df8 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 22 Apr 2026 14:01:50 +0900 Subject: [PATCH 07/43] =?UTF-8?q?feat=20:=20=EB=A9=A4=EB=B2=84=EC=9D=98=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/controller/MemberController.java | 17 +- .../PointHistory/PointHistoryResponse.java | 17 ++ .../service/PointService/PointService.java | 2 + .../PointService/PointServiceImpl.java | 54 ++++++ .../PointService/PointServiceImplTest.java | 169 ++++++++++++++++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java create mode 100644 src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index 19d3a28..5b9bbdf 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -3,11 +3,14 @@ import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; import com.valanse.valanse.service.MemberProfileService.MemberProfileService; +import com.valanse.valanse.service.PointService.PointService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -20,6 +23,7 @@ public class MemberController { private final MemberProfileService memberProfileService; + private final PointService pointService; @Operation( summary = "회원 프로필 정보 저장", @@ -92,4 +96,15 @@ public ResponseEntity getMyProfile() { MemberMyPageResponse response = memberProfileService.getMyProfile(); return ResponseEntity.ok(response); } -} \ No newline at end of file + + @Operation( + summary = "포인트 지급 내역 조회", + description = "현재 로그인한 회원의 포인트 지급 내역을 조회합니다. 최신순으로 정렬되어 반환됩니다." + ) + @GetMapping("/point-history") + public ResponseEntity getPointHistory() { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + PointHistoryResponse response = pointService.getPointHistory(userId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java new file mode 100644 index 0000000..0023188 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java @@ -0,0 +1,17 @@ +package com.valanse.valanse.dto.PointHistory; + +import com.valanse.valanse.domain.enums.PointType; + +import java.util.List; + +public record PointHistoryResponse( + List pointHistory +) { + public record PointHistoryItem( + Long id, + Long amount, + PointType type, + String typeDescription, + String createdAt + ) {} +} diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointService.java b/src/main/java/com/valanse/valanse/service/PointService/PointService.java index 2f46dcd..5f476e5 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointService.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointService.java @@ -1,7 +1,9 @@ package com.valanse.valanse.service.PointService; import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; public interface PointService { void givePoint(Long memberId, PointType type); + PointHistoryResponse getPointHistory(Long memberId); } diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java index 617e1b2..26bdf0a 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java @@ -4,6 +4,7 @@ import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.PointHistory; import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.PointHistoryRepository; @@ -13,6 +14,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; @Service @RequiredArgsConstructor @@ -33,6 +36,9 @@ public class PointServiceImpl implements PointService { // 댓글 포인트 일일 최대 획득 횟수 private static final long COMMENT_DAILY_LIMIT = 3L; + // 날짜 포맷터 + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + @Override public void givePoint(Long memberId, PointType type) { Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) @@ -72,4 +78,52 @@ public void givePoint(Long memberId, PointType type) { pointHistoryRepository.save(history); } } + + @Override + @Transactional(readOnly = true) + public PointHistoryResponse getPointHistory(Long memberId) { + // 회원 존재 여부 확인 + memberRepository.findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + // 포인트 히스토리 조회 (최신순으로 정렬) + List histories = pointHistoryRepository.findByMemberId(memberId); + + // DTO로 변환 + List historyItems = histories.stream() + .sorted((h1, h2) -> { + // null 값 처리: null은 가장 오래된 것으로 간주 + if (h1.getCreatedAt() == null && h2.getCreatedAt() == null) return 0; + if (h1.getCreatedAt() == null) return 1; + if (h2.getCreatedAt() == null) return -1; + return h2.getCreatedAt().compareTo(h1.getCreatedAt()); // 최신순 정렬 + }) + .map(history -> new PointHistoryResponse.PointHistoryItem( + history.getId(), + history.getAmount(), + history.getType(), + getPointTypeDescription(history.getType()), + formatCreatedAt(history.getCreatedAt()) + )) + .toList(); + + return new PointHistoryResponse(historyItems); + } + + private String getPointTypeDescription(PointType type) { + return switch (type) { + case SIGN_UP -> "회원가입"; + case POST_CREATE -> "게시글 작성"; + case COMMENT_CREATE -> "댓글 작성"; + case POST_VOTED -> "투표 참여"; + case HOT_ISSUE -> "핫이슈"; + }; + } + + private String formatCreatedAt(LocalDateTime createdAt) { + if (createdAt == null) { + return null; + } + return createdAt.format(DATE_TIME_FORMATTER); + } } diff --git a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java new file mode 100644 index 0000000..10debac --- /dev/null +++ b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java @@ -0,0 +1,169 @@ +package com.valanse.valanse.service.PointService; + +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.PointHistory; +import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.PointHistoryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PointServiceImplTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PointHistoryRepository pointHistoryRepository; + + @InjectMocks + private PointServiceImpl pointService; + + @Test + @DisplayName("포인트 히스토리 조회 - 성공") + void getPointHistory_Success() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + LocalDateTime now = LocalDateTime.now(); + List histories = Arrays.asList( + PointHistory.builder() + .id(1L) + .member(member) + .amount(40L) + .type(PointType.SIGN_UP) + .build(), + PointHistory.builder() + .id(2L) + .member(member) + .amount(5L) + .type(PointType.POST_CREATE) + .build(), + PointHistory.builder() + .id(3L) + .member(member) + .amount(1L) + .type(PointType.COMMENT_CREATE) + .build() + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + when(pointHistoryRepository.findByMemberId(memberId)) + .thenReturn(histories); + + // When + PointHistoryResponse response = pointService.getPointHistory(memberId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.pointHistory()).hasSize(3); + + PointHistoryResponse.PointHistoryItem firstItem = response.pointHistory().get(0); + assertThat(firstItem.amount()).isEqualTo(40L); + assertThat(firstItem.type()).isEqualTo(PointType.SIGN_UP); + assertThat(firstItem.typeDescription()).isEqualTo("회원가입"); + } + + @Test + @DisplayName("포인트 히스토리 조회 - 회원이 존재하지 않는 경우") + void getPointHistory_MemberNotFound() { + // Given + Long memberId = 999L; + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> pointService.getPointHistory(memberId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("회원을 찾을 수 없습니다."); + } + + @Test + @DisplayName("포인트 타입 설명 확인") + void getPointTypeDescription_AllTypes() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + List histories = Arrays.asList( + PointHistory.builder().id(1L).member(member).amount(40L).type(PointType.SIGN_UP).build(), + PointHistory.builder().id(2L).member(member).amount(5L).type(PointType.POST_CREATE).build(), + PointHistory.builder().id(3L).member(member).amount(1L).type(PointType.COMMENT_CREATE).build(), + PointHistory.builder().id(4L).member(member).amount(1L).type(PointType.POST_VOTED).build(), + PointHistory.builder().id(5L).member(member).amount(50L).type(PointType.HOT_ISSUE).build() + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + when(pointHistoryRepository.findByMemberId(memberId)) + .thenReturn(histories); + + // When + PointHistoryResponse response = pointService.getPointHistory(memberId); + + // Then + List items = response.pointHistory(); + assertThat(items.get(0).typeDescription()).isEqualTo("회원가입"); + assertThat(items.get(1).typeDescription()).isEqualTo("게시글 작성"); + assertThat(items.get(2).typeDescription()).isEqualTo("댓글 작성"); + assertThat(items.get(3).typeDescription()).isEqualTo("투표 참여"); + assertThat(items.get(4).typeDescription()).isEqualTo("핫이슈"); + } + + @Test + @DisplayName("createdAt 날짜 포맷 확인 - YYYY-MM-DD HH:mm:ss 형식") + void getPointHistory_DateFormatting() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + LocalDateTime testDateTime = LocalDateTime.of(2026, 4, 26, 15, 30, 22); + + // PointHistory 객체를 mock으로 생성 + PointHistory mockHistory = mock(PointHistory.class); + when(mockHistory.getId()).thenReturn(1L); + when(mockHistory.getAmount()).thenReturn(40L); + when(mockHistory.getType()).thenReturn(PointType.SIGN_UP); + when(mockHistory.getCreatedAt()).thenReturn(testDateTime); + + List histories = Arrays.asList(mockHistory); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + when(pointHistoryRepository.findByMemberId(memberId)) + .thenReturn(histories); + + // When + PointHistoryResponse response = pointService.getPointHistory(memberId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.pointHistory()).hasSize(1); + + PointHistoryResponse.PointHistoryItem item = response.pointHistory().get(0); + // 날짜가 "2026-04-26 15:30:22" 형식으로 포맷되었는지 확인 + assertThat(item.createdAt()).isEqualTo("2026-04-26 15:30:22"); + assertThat(item.amount()).isEqualTo(40L); + assertThat(item.type()).isEqualTo(PointType.SIGN_UP); + assertThat(item.typeDescription()).isEqualTo("회원가입"); + } +} From 6b29bdb4de2691f2c2a7263bd09b90d992ff8fb4 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 22 Apr 2026 14:07:35 +0900 Subject: [PATCH 08/43] =?UTF-8?q?feat=20:=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EC=A0=91=EA=B7=BC=20=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/valanse/valanse/common/config/SecurityConfig.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index a804ebd..34a2e8a 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -80,9 +80,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 회원 관련 .requestMatchers("/member/**").authenticated() - // 포인트 랭킹 (공개) - .requestMatchers(HttpMethod.GET, "/point/ranking").permitAll() - // 나머지 모든 요청 .anyRequest().authenticated() ) From 7ca91f90270c9ca2e93956608b410fe98ea1a4c3 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 28 Apr 2026 13:36:07 +0900 Subject: [PATCH 09/43] =?UTF-8?q?test:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoteServiceImplTest: 공백 제목, 포인트 지급/미지급, getVoteDetailById(비로그인/투표완료), updatePinStatus(권한없음/기존핀해제/핀해제) 케이스 추가 (9 → 17개) - ReportServiceImplTest: 중복 투표 신고, 존재하지 않는 대상 신고 케이스 추가 (5 → 7개) - CommentLikeServiceImplTest: 존재하지 않는 회원/댓글 좋아요 케이스 추가 (2 → 4개) - VoteControllerTest: GlobalExceptionHandler 응답 형식에 맞게 $.type 검증 제거 --- .../controller/VoteControllerTest.java | 255 +++------- .../CommentLikeServiceImplTest.java | 99 ++-- .../ReportService/ReportServiceImplTest.java | 127 +++-- .../VoteService/VoteServiceImplTest.java | 474 +++++++++++------- 4 files changed, 490 insertions(+), 465 deletions(-) diff --git a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java index 9b96222..f043bc4 100644 --- a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java +++ b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java @@ -5,8 +5,8 @@ import com.valanse.valanse.domain.*; import com.valanse.valanse.domain.enums.*; import com.valanse.valanse.repository.CommentGroupRepository; -import com.valanse.valanse.repository.MemberProfileRepository; // -import com.valanse.valanse.repository.MemberRepository; // +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.VoteRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,241 +21,144 @@ import java.time.LocalDateTime; import java.util.Arrays; -import java.util.List; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@SpringBootTest // Spring Boot 테스트 환경 로드 -@AutoConfigureMockMvc // MockMvc 자동 구성 -@ActiveProfiles("test") // application-test.yml 프로파일 활성화 -@Transactional // 각 테스트 메서드가 끝날 때 트랜잭션 롤백 +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional public class VoteControllerTest { - @Autowired - private MockMvc mockMvc; // HTTP 요청을 시뮬레이션하는 데 사용 + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private VoteRepository voteRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private MemberProfileRepository memberProfileRepository; + @Autowired private CommentGroupRepository commentGroupRepository; - @Autowired - private ObjectMapper objectMapper; // JSON 직렬화/역직렬화를 위한 유틸리티 - - @Autowired - private VoteRepository voteRepository; - - @Autowired - private MemberRepository memberRepository; // Member 저장을 위해 필요 - - @Autowired - private MemberProfileRepository memberProfileRepository; // MemberProfile 저장을 위해 필요 - - @Autowired - private CommentGroupRepository commentGroupRepository; - - @BeforeEach // 각 테스트 메서드 실행 전에 실행 + @BeforeEach void setUp() { - // 데이터 클린업 (Transactional 어노테이션으로 롤백되므로 필수는 아니지만 명시적으로 초기화) voteRepository.deleteAll(); commentGroupRepository.deleteAll(); memberProfileRepository.deleteAll(); memberRepository.deleteAll(); - // 핫이슈 투표 생성 시 필요한 멤버 및 프로필 데이터 생성 - Member member1 = Member.builder() // - .socialId("kakao123") - .email("test1@example.com") - .name("테스터1") + Member member1 = Member.builder() + .socialId("kakao123").email("test1@example.com").name("테스터1") .profile_image_url("http://image.com/test1.jpg") - .kakaoAccessToken("token1") - .kakaoRefreshToken("refresh1") - .build(); - memberRepository.save(member1); // + .kakaoAccessToken("token1").kakaoRefreshToken("refresh1").build(); + memberRepository.save(member1); - MemberProfile profile1 = MemberProfile.builder() // - .member(member1) - .nickname("테스터1닉네임") - .gender(Gender.MALE) // - .age(Age.TWENTY) // - .mbti("ENFP") - .build(); - memberProfileRepository.save(profile1); // + memberProfileRepository.save(MemberProfile.builder() + .member(member1).nickname("테스터1닉네임") + .gender(Gender.MALE).age(Age.TWENTY).mbti("ENFP").build()); - // 투표 생성 - 핫이슈 (가장 높은 반응성) - Vote hotIssueVote = Vote.builder() // - .category(VoteCategory.FOOD) // <-- 88번 줄: VoteCategory enum 값을 올바르게 할당 - .title("오늘의 점심 선택은?") - .totalVoteCount(100) - .reactivityScore(110) // 투표 100 + 댓글 10 = 반응성 110 - .reactivityUpdatedAt(LocalDateTime.now()) // 현재 시간으로 설정 - .member(member1)// - .pinType(PinType.NONE) - .build(); + Vote hotIssueVote = Vote.builder() + .category(VoteCategory.FOOD).title("오늘의 점심 선택은?") + .totalVoteCount(100).reactivityScore(110) + .reactivityUpdatedAt(LocalDateTime.now()) + .member(member1).pinType(PinType.NONE).build(); voteRepository.save(hotIssueVote); - // CommentGroup 생성 (댓글 10개) - CommentGroup commentGroup = CommentGroup.builder() - .vote(hotIssueVote) - .totalCommentCount(10) - .build(); - commentGroupRepository.save(commentGroup); + commentGroupRepository.save(CommentGroup.builder() + .vote(hotIssueVote).totalCommentCount(10).build()); - - // 핫이슈 투표 옵션들 - VoteOption optionA = VoteOption.builder() // - .vote(hotIssueVote) - .content("A. 맵고 얼큰한 라면") - .voteCount(60) - .label(VoteLabel.A) // - .build(); - VoteOption optionB = VoteOption.builder() // - .vote(hotIssueVote) - .content("B. 부드러운 파스타") - .voteCount(40) - .label(VoteLabel.B) // - .build(); + VoteOption optionA = VoteOption.builder().vote(hotIssueVote) + .content("A. 맵고 얼큰한 라면").voteCount(60).label(VoteLabel.A).build(); + VoteOption optionB = VoteOption.builder().vote(hotIssueVote) + .content("B. 부드러운 파스타").voteCount(40).label(VoteLabel.B).build(); hotIssueVote.getVoteOptions().addAll(Arrays.asList(optionA, optionB)); - voteRepository.save(hotIssueVote); // 옵션 추가 후 다시 저장 + voteRepository.save(hotIssueVote); - // 다른 투표 생성 (반응성이 더 낮은 투표) - Member member2 = Member.builder() // - .socialId("kakao456") - .email("test2@example.com") - .name("테스터2") + Member member2 = Member.builder() + .socialId("kakao456").email("test2@example.com").name("테스터2") .profile_image_url("http://image.com/test2.jpg") - .kakaoAccessToken("token2") - .kakaoRefreshToken("refresh2") - .build(); - memberRepository.save(member2); // + .kakaoAccessToken("token2").kakaoRefreshToken("refresh2").build(); + memberRepository.save(member2); - MemberProfile profile2 = MemberProfile.builder() // - .member(member2) - .nickname("테스터2닉네임") - .gender(Gender.FEMALE) // - .age(Age.THIRTY) // - .mbti("ISTJ") - .build(); - memberProfileRepository.save(profile2); // + memberProfileRepository.save(MemberProfile.builder() + .member(member2).nickname("테스터2닉네임") + .gender(Gender.FEMALE).age(Age.THIRTY).mbti("ISTJ").build()); - // 다른 투표 생성 (반응성이 더 낮은 투표) - Vote otherVote = Vote.builder() // - .category(VoteCategory.LOVE) // <-- 133번 줄: VoteCategory enum 값을 올바르게 할당 - .title("연애 밸런스 게임") - .totalVoteCount(50) - .reactivityScore(55) // 투표 50 + 댓글 5 = 반응성 55 + Vote otherVote = Vote.builder() + .category(VoteCategory.LOVE).title("연애 밸런스 게임") + .totalVoteCount(50).reactivityScore(55) .reactivityUpdatedAt(LocalDateTime.now()) - .member(member2) - .pinType(PinType.NONE)// - .build(); + .member(member2).pinType(PinType.NONE).build(); voteRepository.save(otherVote); - CommentGroup commentGroup2 = CommentGroup.builder() - .vote(otherVote) - .totalCommentCount(5) - .build(); - commentGroupRepository.save(commentGroup2); + commentGroupRepository.save(CommentGroup.builder() + .vote(otherVote).totalCommentCount(5).build()); } @Test @DisplayName("반응성이 가장 높은 핫이슈 투표 정보를 성공적으로 조회한다.") void getHotIssueVote_Success() throws Exception { - mockMvc.perform(get("/votes/best") // GET 요청 - .contentType(MediaType.APPLICATION_JSON)) // 요청 타입 - .andExpect(status().isOk()) // HTTP 상태 코드 200 OK 확인 - .andExpect(jsonPath("$.voteId").isNumber()) // voteId가 숫자인지 확인 - .andExpect(jsonPath("$.title").value("오늘의 점심 선택은?")) // 제목 확인 반응성 1위 - .andExpect(jsonPath("$.category").value(VoteCategory.FOOD.name())) // 카테고리 확인 - .andExpect(jsonPath("$.totalParticipants").value(100)) // 총 투표수 확인 - .andExpect(jsonPath("$.createdBy").value("테스터1닉네임")) // 생성자 닉네임 확인 - .andExpect(jsonPath("$.options[0].content").value("A. 맵고 얼큰한 라면")) // 첫 번째 옵션 내용 확인 - .andExpect(jsonPath("$.options[0].vote_count").value(60)) // 첫 번째 옵션 투표 수 확인 - .andExpect(jsonPath("$.options[1].content").value("B. 부드러운 파스타")) // 두 번째 옵션 내용 확인 - .andExpect(jsonPath("$.options[1].vote_count").value(40)); // 두 번째 옵션 투표 수 확인 + mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.voteId").isNumber()) + .andExpect(jsonPath("$.title").value("오늘의 점심 선택은?")) + .andExpect(jsonPath("$.category").value(VoteCategory.FOOD.name())) + .andExpect(jsonPath("$.totalParticipants").value(100)) + .andExpect(jsonPath("$.createdBy").value("테스터1닉네임")) + .andExpect(jsonPath("$.options[0].content").value("A. 맵고 얼큰한 라면")) + .andExpect(jsonPath("$.options[0].vote_count").value(60)) + .andExpect(jsonPath("$.options[1].content").value("B. 부드러운 파스타")) + .andExpect(jsonPath("$.options[1].vote_count").value(40)); } @Test @DisplayName("핫이슈 투표가 없을 때 404 Not Found를 반환한다.") void getHotIssueVote_NotFound() throws Exception { - // 모든 투표 삭제하여 핫이슈 투표가 없는 상태로 만듦 commentGroupRepository.deleteAll(); voteRepository.deleteAll(); - mockMvc.perform(get("/votes/best") // GET 요청 - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isNotFound()) // HTTP 상태 코드 404 Not Found 확인 - .andExpect(jsonPath("$.error").value("핫이슈 투표를 찾을 수 없습니다.")) // 에러 메시지 확인 - .andExpect(jsonPath("$.status").value(404)) // 상태 코드 확인 - .andExpect(jsonPath("$.type").value("ApiException")); // 예외 타입 확인 + mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("핫이슈 투표를 찾을 수 없습니다.")) + .andExpect(jsonPath("$.status").value(404)); + // 참고: GlobalExceptionHandler에서 "type" 필드는 주석 처리되어 있음 } @Test @DisplayName("동일한 반응성을 가진 투표 중 최신 투표를 조회한다.") void getHotIssueVote_SameTotalVoteCount_NewerIsHotIssue() throws Exception { - // 데이터 클린업 commentGroupRepository.deleteAll(); voteRepository.deleteAll(); - // 멤버 생성 Member member3 = Member.builder() - .socialId("kakao789") - .email("test3@example.com") - .name("테스터3") + .socialId("kakao789").email("test3@example.com").name("테스터3") .profile_image_url("http://image.com/test3.jpg") - .kakaoAccessToken("token3") - .kakaoRefreshToken("refresh3") - .build(); + .kakaoAccessToken("token3").kakaoRefreshToken("refresh3").build(); memberRepository.save(member3); - MemberProfile profile3 = MemberProfile.builder() - .member(member3) - .nickname("테스터3닉네임") - .gender(Gender.MALE) - .age(Age.OVER_FORTY) - .mbti("INTP") - .build(); - memberProfileRepository.save(profile3); + memberProfileRepository.save(MemberProfile.builder() + .member(member3).nickname("테스터3닉네임") + .gender(Gender.MALE).age(Age.OVER_FORTY).mbti("INTP").build()); - // 오래된 투표 (반응성: 50) Vote oldVote = Vote.builder() - .category(VoteCategory.ETC) - .title("오래된 핫이슈 투표") - .totalVoteCount(50) - .reactivityScore(50) // 추가 - .reactivityUpdatedAt(LocalDateTime.now().minusDays(3)) // 3일 전 - .member(member3) - .pinType(PinType.NONE) - .build(); + .category(VoteCategory.ETC).title("오래된 핫이슈 투표") + .totalVoteCount(50).reactivityScore(50) + .reactivityUpdatedAt(LocalDateTime.now().minusDays(3)) + .member(member3).pinType(PinType.NONE).build(); voteRepository.save(oldVote); + commentGroupRepository.save(CommentGroup.builder().vote(oldVote).totalCommentCount(0).build()); - // CommentGroup 생성 - CommentGroup commentGroup3 = CommentGroup.builder() - .vote(oldVote) - .totalCommentCount(0) - .build(); - commentGroupRepository.save(commentGroup3); - - // 새로운 투표 (동일한 반응성: 50, 하지만 더 최신) Vote newVote = Vote.builder() - .category(VoteCategory.LOVE) - .title("새로운 핫이슈 투표") - .totalVoteCount(50) - .reactivityScore(50) // 추가 - .reactivityUpdatedAt(LocalDateTime.now()) // 현재 시간 - .member(member3) - .pinType(PinType.NONE) - .build(); + .category(VoteCategory.LOVE).title("새로운 핫이슈 투표") + .totalVoteCount(50).reactivityScore(50) + .reactivityUpdatedAt(LocalDateTime.now()) + .member(member3).pinType(PinType.NONE).build(); voteRepository.save(newVote); + commentGroupRepository.save(CommentGroup.builder().vote(newVote).totalCommentCount(0).build()); - // CommentGroup 생성 - CommentGroup commentGroup4 = CommentGroup.builder() - .vote(newVote) - .totalCommentCount(0) - .build(); - commentGroupRepository.save(commentGroup4); - - mockMvc.perform(get("/votes/best") - .contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("새로운 핫이슈 투표")) // 최신 투표가 선택 + .andExpect(jsonPath("$.title").value("새로운 핫이슈 투표")) .andExpect(jsonPath("$.totalParticipants").value(50)) .andExpect(jsonPath("$.createdBy").value("테스터3닉네임")); } -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java b/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java index 49e43ed..93c147b 100644 --- a/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/CommentLikeService/CommentLikeServiceImplTest.java @@ -1,5 +1,6 @@ package com.valanse.valanse.service.CommentLikeService; +import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Comment; import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.mapping.CommentLike; @@ -8,6 +9,7 @@ import com.valanse.valanse.repository.CommentRepository; import com.valanse.valanse.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -20,91 +22,82 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class CommentLikeServiceImplTest { + @InjectMocks private CommentLikeServiceImpl commentLikeService; - @Mock - private CommentRepository commentRepository; - @Mock - private MemberRepository memberRepository; - @Mock - private CommentLikeRepository commentLikeRepository; + @Mock private CommentRepository commentRepository; + @Mock private MemberRepository memberRepository; + @Mock private CommentLikeRepository commentLikeRepository; - // Security에서 userId를 뽑아오기 때문에 미리 설정 @BeforeEach void setupSecurityContext() { - Authentication authentication = mock(Authentication.class); - when(authentication.getName()).thenReturn("1"); // userId=1 가정 - SecurityContext securityContext = mock(SecurityContext.class); - when(securityContext.getAuthentication()).thenReturn(authentication); - SecurityContextHolder.setContext(securityContext); + Authentication auth = mock(Authentication.class); + when(auth.getName()).thenReturn("1"); + SecurityContext ctx = mock(SecurityContext.class); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); } - @Test - public void 좋아요_test() { - //given + @DisplayName("좋아요가 없는 댓글에 좋아요를 누르면 likeCount가 1 증가한다") + void 좋아요_성공() { Member member = new Member(); + Comment comment = Comment.builder().content("댓글").likeCount(0).build(); + CommentLike commentLike = CommentLike.builder().comment(comment).user(member).build(); - Comment comment = Comment.builder() - .content("좋아요 없는 댓글") - .likeCount(0) - .build(); - - CommentLike commentLike = CommentLike.builder() - .comment(comment) - .user(member) - .build(); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); - // 첫번째 호출에서는 빈값, 두번째 호출에서는 commentLike 반환. when(commentLikeRepository.findByUserIdAndCommentId(any(), any())) .thenReturn(Optional.empty()) .thenReturn(Optional.of(commentLike)); - // when - CommentLikeResponseDto responseDto = commentLikeService.likeComment(1L, 1L); + CommentLikeResponseDto result = commentLikeService.likeComment(1L, 1L); - //then: 응답 dto 반환 값 확인 - assertThat(responseDto.getLikeCount()).isEqualTo(1); - assertThat(responseDto.getMessage()).isEqualTo("좋아요 성공"); + assertThat(result.getLikeCount()).isEqualTo(1); + assertThat(result.getMessage()).isEqualTo("좋아요 성공"); } @Test - void 좋아요_취소_test() { - // when + @DisplayName("이미 좋아요를 누른 댓글에 다시 누르면 취소되고 likeCount가 감소한다") + void 좋아요_취소() { Member member = new Member(); + Comment comment = Comment.builder().content("댓글").likeCount(1).build(); + CommentLike commentLike = CommentLike.builder().comment(comment).user(member).build(); - Comment comment = Comment.builder() - .content("좋아요 누른 댓글") - .likeCount(1) - .build(); - - CommentLike commentLike = CommentLike.builder() - .comment(comment) - .user(member) - .build(); - - // stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); when(commentLikeRepository.findByUserIdAndCommentId(any(), any())) .thenReturn(Optional.of(commentLike)) .thenReturn(Optional.empty()); - //when - CommentLikeResponseDto responseDto = commentLikeService.likeComment(1L, 1L); + CommentLikeResponseDto result = commentLikeService.likeComment(1L, 1L); + + assertThat(result.getLikeCount()).isEqualTo(0); + assertThat(result.getMessage()).isEqualTo("좋아요 취소"); + } + + @Test + @DisplayName("존재하지 않는 회원이 좋아요를 시도하면 예외가 발생한다") + void 좋아요_존재하지않는회원() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.empty()); + + assertThrows(Exception.class, () -> commentLikeService.likeComment(1L, 1L)); + verify(commentRepository, never()).findById(any()); + } + + @Test + @DisplayName("존재하지 않는 댓글에 좋아요를 시도하면 예외가 발생한다") + void 좋아요_존재하지않는댓글() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); + when(commentRepository.findById(any())).thenReturn(Optional.empty()); - //then: 응답 dto 반환 값 확인 - assertThat(responseDto.getLikeCount()).isEqualTo(0); - assertThat(responseDto.getMessage()).isEqualTo("좋아요 취소"); + assertThrows(Exception.class, () -> commentLikeService.likeComment(1L, 999L)); } - -} \ No newline at end of file +} diff --git a/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java b/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java index eed9bdd..d742fe5 100644 --- a/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/ReportService/ReportServiceImplTest.java @@ -9,6 +9,7 @@ import com.valanse.valanse.repository.CommentRepository; import com.valanse.valanse.repository.ReportRepository; import com.valanse.valanse.repository.VoteRepository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -21,114 +22,140 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ReportServiceImplTest { - @InjectMocks - private ReportServiceImpl reportService; - @Mock - private ReportRepository reportRepository; + @InjectMocks private ReportServiceImpl reportService; - @Mock - private VoteRepository voteRepository; + @Mock private ReportRepository reportRepository; + @Mock private VoteRepository voteRepository; + @Mock private CommentRepository commentRepository; - @Mock - private CommentRepository commentRepository; + // ────────────────────────────────────────────── + // 정상 신고 + // ────────────────────────────────────────────── @Test - void 댓글_신고() { - //given - Member member = Member.builder().id(1L).build(); + @DisplayName("타인의 댓글을 신고하면 Report가 저장된다") + void 댓글_신고_성공() { + Member reporter = Member.builder().id(1L).build(); Member writer = Member.builder().id(2L).build(); Comment comment = Comment.builder().member(writer).build(); - //stub when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(false); - //when - reportService.report(member, 1L, ReportType.COMMENT); + reportService.report(reporter, 1L, ReportType.COMMENT); - //then ArgumentCaptor captor = ArgumentCaptor.forClass(Report.class); verify(reportRepository).save(captor.capture()); - Report report = captor.getValue(); - assertEquals(member, report.getMember()); + assertThat(captor.getValue().getMember()).isEqualTo(reporter); } @Test - void 게임_신고() { - //given - Member member = Member.builder().id(1L).build(); + @DisplayName("타인의 투표를 신고하면 Report가 저장된다") + void 게임_신고_성공() { + Member reporter = Member.builder().id(1L).build(); Member writer = Member.builder().id(2L).build(); Vote vote = Vote.builder().member(writer).build(); - //stub when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(false); - //when - reportService.report(member, 1L, ReportType.VOTE); + reportService.report(reporter, 1L, ReportType.VOTE); - //then ArgumentCaptor captor = ArgumentCaptor.forClass(Report.class); verify(reportRepository).save(captor.capture()); - Report report = captor.getValue(); - assertEquals(member, report.getMember()); + assertThat(captor.getValue().getMember()).isEqualTo(reporter); } + // ────────────────────────────────────────────── + // 자기 자신 신고 불가 + // ────────────────────────────────────────────── + @Test + @DisplayName("본인 댓글은 신고할 수 없다") void 본인_댓글_신고_불가() { - //given Member writer = Member.builder().id(2L).build(); Comment comment = Comment.builder().member(writer).build(); - //stub when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); - //when - ApiException apiException = assertThrows(ApiException.class, () -> reportService.report(writer, 1L, ReportType.COMMENT)); - - //then - assertThat(apiException.getMessage()).isEqualTo("자신의 댓글은 신고할 수 없습니다."); + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(writer, 1L, ReportType.COMMENT)); + assertThat(ex.getMessage()).isEqualTo("자신의 댓글은 신고할 수 없습니다."); } @Test + @DisplayName("본인 투표는 신고할 수 없다") void 본인_게임_신고_불가() { - //given Member writer = Member.builder().id(2L).build(); Vote vote = Vote.builder().member(writer).build(); - //stub when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); - //when - ApiException apiException = assertThrows(ApiException.class, () -> reportService.report(writer, 1L, ReportType.VOTE)); - - //then - assertThat(apiException.getMessage()).isEqualTo("자신의 투표는 신고할 수 없습니다."); + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(writer, 1L, ReportType.VOTE)); + assertThat(ex.getMessage()).isEqualTo("자신의 투표는 신고할 수 없습니다."); } + // ────────────────────────────────────────────── + // 중복 신고 방지 + // ────────────────────────────────────────────── + @Test + @DisplayName("같은 댓글을 두 번 신고하면 예외가 발생한다") void 중복_신고_불가() { - //given - Member member = Member.builder().id(1L).build(); + Member reporter = Member.builder().id(1L).build(); Member writer = Member.builder().id(2L).build(); Comment comment = Comment.builder().member(writer).build(); - //stub when(commentRepository.findById(any())).thenReturn(Optional.of(comment)); when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(true); - //when - ApiException apiException = assertThrows(ApiException.class, () -> reportService.report(member, 1L, ReportType.COMMENT)); + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(reporter, 1L, ReportType.COMMENT)); + assertThat(ex.getMessage()).isEqualTo("이미 신고한 대상입니다."); + } + + @Test + @DisplayName("같은 투표를 두 번 신고하면 예외가 발생한다") + void 중복_게임_신고_불가() { + Member reporter = Member.builder().id(1L).build(); + Member writer = Member.builder().id(2L).build(); + Vote vote = Vote.builder().member(writer).build(); - //then - assertThat(apiException.getMessage()).isEqualTo("이미 신고한 대상입니다."); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(reportRepository.existsByMemberAndReportTypeAndTargetId(any(), any(), any())).thenReturn(true); + + ApiException ex = assertThrows(ApiException.class, + () -> reportService.report(reporter, 1L, ReportType.VOTE)); + assertThat(ex.getMessage()).isEqualTo("이미 신고한 대상입니다."); } + // ────────────────────────────────────────────── + // 존재하지 않는 대상 신고 + // ────────────────────────────────────────────── -} \ No newline at end of file + @Test + @DisplayName("존재하지 않는 댓글을 신고하면 예외가 발생한다") + void 존재하지않는_댓글_신고() { + Member reporter = Member.builder().id(1L).build(); + when(commentRepository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(Exception.class, () -> reportService.report(reporter, 999L, ReportType.COMMENT)); + verify(reportRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 투표를 신고하면 예외가 발생한다") + void 존재하지않는_투표_신고() { + Member reporter = Member.builder().id(1L).build(); + when(voteRepository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(Exception.class, () -> reportService.report(reporter, 999L, ReportType.VOTE)); + verify(reportRepository, never()).save(any()); + } +} diff --git a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java index e111f4c..bbd62ba 100644 --- a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java @@ -5,13 +5,17 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.Vote; import com.valanse.valanse.domain.VoteOption; +import com.valanse.valanse.domain.enums.PinType; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.VoteCategory; +import com.valanse.valanse.domain.enums.VoteLabel; import com.valanse.valanse.domain.mapping.MemberVoteOption; import com.valanse.valanse.dto.Vote.VoteCancleResponseDto; import com.valanse.valanse.dto.Vote.VoteCreateRequest; +import com.valanse.valanse.dto.Vote.VoteDetailResponse; import com.valanse.valanse.repository.*; import com.valanse.valanse.service.PointService.PointService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -20,6 +24,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; import java.util.Optional; @@ -35,66 +42,83 @@ class VoteServiceImplTest { @InjectMocks private VoteServiceImpl voteService; - @Mock - private VoteRepository voteRepository; - @Mock - private MemberRepository memberRepository; - @Mock - private MemberVoteOptionRepository memberVoteOptionRepository; - @Mock - private CommentGroupRepository commentGroupRepository; - @Mock - private VoteOptionRepository voteOptionRepository; - @Mock - private PointService pointService; + @Mock private VoteRepository voteRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberVoteOptionRepository memberVoteOptionRepository; + @Mock private CommentGroupRepository commentGroupRepository; + @Mock private VoteOptionRepository voteOptionRepository; + @Mock private MemberProfileRepository memberProfileRepository; + @Mock private PointService pointService; + + // ────────────────────────────────────────────── + // createVote + // ────────────────────────────────────────────── @Test - @DisplayName("투표 제목은 너무 길거나 너무 짧으면 안된다.") - void 제목길이실패_test() { + @DisplayName("투표 제목이 비어있으면 예외가 발생한다") + void 제목길이실패_빈제목() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); - //given - Member member = new Member(); - when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + VoteCreateRequest request = VoteCreateRequest.builder().title("").build(); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("공백만으로 된 제목은 비어있는 것으로 처리된다") + void 제목길이실패_공백만있는제목() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); + + VoteCreateRequest request = VoteCreateRequest.builder().title(" ").build(); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + } + + @Test + @DisplayName("투표 제목이 25자를 초과하면 예외가 발생한다") + void 제목길이실패_26자() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); - // 주석 부분 바꿔가며 확인 가능 VoteCreateRequest request = VoteCreateRequest.builder() -// .title("123123123123123123123123123123") - .title("") + .title("123456789012345678901234567890") // 30자 .build(); - // when - ApiException exception = assertThrows(ApiException.class, - () -> voteService.createVote(1L, request)); + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + } - // then - assertThat(exception.getMessage()).isEqualTo("투표 제목은 1자 이상 25자 이하여야 합니다 (공백 제외)."); + @Test + @DisplayName("투표 옵션이 null이면 예외가 발생한다") + void 투표옵션실패_null() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); + + VoteCreateRequest request = VoteCreateRequest.builder() + .title("테스트 투표").options(null).build(); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 옵션은 1개 이상 4개 이하여야 합니다."); } @Test - @DisplayName("투표 옵션은 너무 많아도, 너무 적어도 안된다.") - void 투표옵션실패_test() { - // given - Member member = new Member(); - when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + @DisplayName("투표 옵션이 5개 이상이면 예외가 발생한다") + void 투표옵션실패_5개() { + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(new Member())); - // 주석 부분 바꿔가며 테스트 가능 VoteCreateRequest request = VoteCreateRequest.builder() - .title("테스트용 투표") -// .options(List.of("1번", "2번", "3번", "4번", "5번")) - .options(null) + .title("테스트 투표") + .options(List.of("1", "2", "3", "4", "5")) .build(); - // when - ApiException exception = assertThrows(ApiException.class, - () -> voteService.createVote(1L, request)); - //then - assertThat(exception.getMessage()).isEqualTo("투표 옵션은 1개 이상 4개 이하여야 합니다."); - assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + ApiException ex = assertThrows(ApiException.class, () -> voteService.createVote(1L, request)); + assertThat(ex.getMessage()).isEqualTo("투표 옵션은 1개 이상 4개 이하여야 합니다."); } @Test - void 투표생성_test() { - // given + @DisplayName("투표 생성 시 voteRepository, commentGroupRepository, pointService가 각 1회 호출된다") + void 투표생성_성공() { Member member = new Member(); VoteCreateRequest request = VoteCreateRequest.builder() @@ -103,214 +127,292 @@ class VoteServiceImplTest { .category(VoteCategory.ALL) .build(); - Vote vote = Vote.builder() - .id(100L) - .title(request.getTitle()) - .content(request.getContent()) - .category(request.getCategory()) - .member(member) - .build(); + Vote savedVote = Vote.builder() + .id(100L).title(request.getTitle()) + .category(request.getCategory()).member(member).build(); - //stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); - when(voteRepository.save(any())).thenReturn(vote); + when(voteRepository.save(any())).thenReturn(savedVote); - // when Long voteId = voteService.createVote(1L, request); - // then assertThat(voteId).isEqualTo(100L); - verify(voteRepository).save(any(Vote.class)); - verify(commentGroupRepository).save(any(CommentGroup.class)); + ArgumentCaptor voteCaptor = ArgumentCaptor.forClass(Vote.class); + verify(voteRepository).save(voteCaptor.capture()); + assertThat(voteCaptor.getValue().getVoteOptions()).hasSize(4); - ArgumentCaptor voteArgumentCaptor = ArgumentCaptor.forClass(Vote.class); - verify(voteRepository).save(voteArgumentCaptor.capture()); - Vote captorVote = voteArgumentCaptor.getValue(); - assertThat(captorVote.getVoteOptions()).hasSize(4); + verify(commentGroupRepository).save(any(CommentGroup.class)); + verify(pointService, times(1)).givePoint(any(), any()); } - @Test - void 처음투표하기_test() { - // given - Member member = Member.builder() - .id(1L) - .build(); - Vote vote = Vote.builder() - .id(10L) - .totalVoteCount(7) - .build(); - - VoteOption voteOption = VoteOption.builder() - .id(100L) - .voteCount(3) - .vote(vote) - .build(); + // ────────────────────────────────────────────── + // processVote + // ────────────────────────────────────────────── + @Test + @DisplayName("처음 투표 시 카운트가 증가하고 isVoted=true를 반환한다") + void 처음투표하기() { + Member member = Member.builder().id(1L).build(); + Member postOwner = Member.builder().id(99L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(7).member(postOwner).build(); + VoteOption voteOption = VoteOption.builder().id(100L).voteCount(3).vote(vote).build(); - // stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); when(voteOptionRepository.findById(any())).thenReturn(Optional.of(voteOption)); when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.empty()); - // when - VoteCancleResponseDto responseDto = voteService.processVote(1L, 10L, 100L); + VoteCancleResponseDto result = voteService.processVote(1L, 10L, 100L); - // then : 응답 dto 속성 검증 - assertThat(responseDto.getTotalVoteCount()).isEqualTo(8); - assertThat(responseDto.getVoteOptionCount()).isEqualTo(4); - assertThat(responseDto.isVoted()).isTrue(); + assertThat(result.getTotalVoteCount()).isEqualTo(8); + assertThat(result.getVoteOptionCount()).isEqualTo(4); + assertThat(result.isVoted()).isTrue(); } @Test - void 투표취소_test() { + @DisplayName("처음 투표 시 본인 게시물이 아니면 작성자에게 포인트가 지급된다") + void 처음투표_포인트지급() { + Member voter = Member.builder().id(1L).build(); + Member postOwner = Member.builder().id(99L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(0).member(postOwner).build(); + VoteOption option = VoteOption.builder().id(100L).voteCount(0).vote(vote).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(voter)); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(option)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.empty()); - // given - Member member = Member.builder() - .id(1L) - .build(); + voteService.processVote(1L, 10L, 100L); - Vote vote = Vote.builder() - .id(10L) - .totalVoteCount(7) - .build(); + verify(pointService, times(1)).givePoint(eq(99L), any()); + } - VoteOption voteOption = VoteOption.builder() - .id(100L) - .voteCount(3) - .vote(vote) - .build(); + @Test + @DisplayName("본인 게시물에 투표해도 포인트가 지급되지 않는다") + void 처음투표_본인게시물_포인트미지급() { + Member member = Member.builder().id(1L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(0).member(member).build(); // 본인 게시물 + VoteOption option = VoteOption.builder().id(100L).voteCount(0).vote(vote).build(); - MemberVoteOption mvo = MemberVoteOption.builder() - .voteOption(voteOption) - .build(); - //stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); - when(voteOptionRepository.findById(any())).thenReturn(Optional.of(voteOption)); - when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.of(mvo)); - - //when - VoteCancleResponseDto responseDto = voteService.processVote(1L, 10L, 100L); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(option)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.empty()); - //then: dto 값 검증, memberVoteOption 삭제 호출 1회 검증 - assertThat(responseDto.getTotalVoteCount()).isEqualTo(6); - assertThat(responseDto.getVoteOptionCount()).isEqualTo(2); - assertThat(responseDto.isVoted()).isFalse(); + voteService.processVote(1L, 10L, 100L); - verify(memberVoteOptionRepository,times(1)).delete(any(MemberVoteOption.class)); + verify(pointService, never()).givePoint(any(), any()); } @Test - void 투표재선택_test(){ + @DisplayName("동일 선택지를 다시 클릭하면 투표가 취소되고 카운트가 감소한다") + void 투표취소() { + Member member = Member.builder().id(1L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(7).build(); + VoteOption voteOption = VoteOption.builder().id(100L).voteCount(3).vote(vote).build(); + MemberVoteOption mvo = MemberVoteOption.builder().voteOption(voteOption).build(); - // given - Member member = Member.builder() - .id(1L) - .build(); - Vote vote = Vote.builder() - .id(10L) - .totalVoteCount(7) - .build(); + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(voteOption)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.of(mvo)); - VoteOption voteOption = VoteOption.builder() - .id(100L) - .voteCount(3) - .vote(vote) - .build(); - VoteOption newVoteOption = VoteOption.builder() - .id(101L) - .voteCount(10) - .vote(vote) - .build(); + VoteCancleResponseDto result = voteService.processVote(1L, 10L, 100L); + + assertThat(result.getTotalVoteCount()).isEqualTo(6); + assertThat(result.getVoteOptionCount()).isEqualTo(2); + assertThat(result.isVoted()).isFalse(); + verify(memberVoteOptionRepository, times(1)).delete(any(MemberVoteOption.class)); + } + + @Test + @DisplayName("다른 선택지로 재선택하면 기존 카운트가 감소하고 새 카운트가 증가한다") + void 투표재선택() { + Member member = Member.builder().id(1L).build(); + Vote vote = Vote.builder().id(10L).totalVoteCount(7).build(); + VoteOption oldOption = VoteOption.builder().id(100L).voteCount(3).vote(vote).build(); + VoteOption newOption = VoteOption.builder().id(101L).voteCount(10).vote(vote).build(); + MemberVoteOption mvo = MemberVoteOption.builder().voteOption(oldOption).build(); - MemberVoteOption mvo = MemberVoteOption.builder() - .voteOption(voteOption) - .build(); - //stub when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); - when(voteOptionRepository.findById(any())).thenReturn(Optional.of(newVoteOption)); + when(voteOptionRepository.findById(any())).thenReturn(Optional.of(newOption)); when(memberVoteOptionRepository.findByMemberIdAndVoteId(any(), any())).thenReturn(Optional.of(mvo)); - //when - VoteCancleResponseDto responseDto = voteService.processVote(1L, 10L, 101L); - - //then: repository 동작, 응답 dto 속성 검증 - assertThat(responseDto.getTotalVoteCount()).isEqualTo(7); - assertThat(responseDto.getVoteOptionCount()).isEqualTo(11); - assertThat(responseDto.isVoted()).isTrue(); + VoteCancleResponseDto result = voteService.processVote(1L, 10L, 101L); + assertThat(result.getTotalVoteCount()).isEqualTo(7); // 총 투표 수 변동 없음 + assertThat(result.getVoteOptionCount()).isEqualTo(11); // 새 옵션 +1 + assertThat(result.isVoted()).isTrue(); + assertThat(oldOption.getVoteCount()).isEqualTo(2); // 기존 옵션 -1 verify(memberVoteOptionRepository, times(1)).delete(any(MemberVoteOption.class)); verify(memberVoteOptionRepository, times(1)).save(any(MemberVoteOption.class)); - verify(voteOptionRepository, times(2)).save(any(VoteOption.class)); } + // ────────────────────────────────────────────── + // getVoteDetailById + // ────────────────────────────────────────────── + @Test - @DisplayName("관리자의 권한으로 다른 사용자의 투표를 삭제한다.") - void 관리자권한_투표삭제_test() { - //given - Member admin = Member.builder() - .id(1L) - .role(Role.ADMIN) - .build(); + @DisplayName("존재하지 않는 투표 ID 조회 시 404 예외가 발생한다") + void 투표상세조회_없는ID() { + when(voteRepository.findById(any())).thenReturn(Optional.empty()); - Member member = Member.builder() - .id(2L) - .build(); + ApiException ex = assertThrows(ApiException.class, () -> voteService.getVoteDetailById(999L)); + assertThat(ex.getMessage()).isEqualTo("투표를 찾을 수 없습니다."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + @Test + @DisplayName("비로그인 사용자는 hasVoted=false로 투표 상세를 조회할 수 있다") + void 투표상세조회_비로그인() { + // SecurityContext를 익명 사용자로 설정 + Authentication auth = mock(Authentication.class); + when(auth.getName()).thenReturn("anonymousUser"); + SecurityContext ctx = mock(SecurityContext.class); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); + + VoteOption option = VoteOption.builder() + .id(1L).content("A선택지").voteCount(5).label(VoteLabel.A).build(); Vote vote = Vote.builder() - .member(member) - .id(10L) - .totalVoteCount(7) - .build(); + .id(10L).title("테스트 투표").totalVoteCount(10).build(); + vote.addVoteOption(option); - //stub - when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(admin)); - when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.findById(10L)).thenReturn(Optional.of(vote)); - //when - voteService.deleteVote(1L,10L); + VoteDetailResponse result = voteService.getVoteDetailById(10L); - //then - ArgumentCaptor voteArgumentCaptor = ArgumentCaptor.forClass(Vote.class); - verify(voteRepository).save(voteArgumentCaptor.capture()); - Vote captorVote = voteArgumentCaptor.getValue(); - assertThat((captorVote.getDeletedAt())).isNotNull(); + assertThat(result.getHasVoted()).isFalse(); + assertThat(result.getVotedOptionLabel()).isNull(); + assertThat(result.getOptions()).hasSize(1); } @Test - @DisplayName("관리자가 아니면 다른 사용자의 투표를 삭제할 수 없다.") - void 관리자_투표삭제_실패test() { - //given - Member admin = Member.builder() - .id(1L) - .role(Role.USER) - .build(); + @DisplayName("로그인 사용자가 투표한 경우 hasVoted=true와 선택지 라벨을 반환한다") + void 투표상세조회_이미투표한경우() { + Authentication auth = mock(Authentication.class); + when(auth.getName()).thenReturn("1"); + SecurityContext ctx = mock(SecurityContext.class); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); + + VoteOption option = VoteOption.builder() + .id(100L).content("A선택지").voteCount(5).label(VoteLabel.A).build(); + Vote vote = Vote.builder() + .id(10L).title("테스트 투표").totalVoteCount(10).build(); + vote.addVoteOption(option); - Member member = Member.builder() - .id(2L) - .build(); + MemberVoteOption mvo = MemberVoteOption.builder().voteOption(option).build(); - Vote vote = Vote.builder() - .member(member) - .id(10L) - .totalVoteCount(7) - .build(); - // stub + when(voteRepository.findById(10L)).thenReturn(Optional.of(vote)); + when(memberVoteOptionRepository.findByMemberIdAndVoteId(1L, 10L)).thenReturn(Optional.of(mvo)); + + VoteDetailResponse result = voteService.getVoteDetailById(10L); + + assertThat(result.getHasVoted()).isTrue(); + assertThat(result.getVotedOptionLabel()).isEqualTo("A"); + } + + // ────────────────────────────────────────────── + // deleteVote + // ────────────────────────────────────────────── + + @Test + @DisplayName("본인이 만든 투표를 삭제하면 softDelete가 적용된다") + void 본인_투표삭제_성공() { + Member member = Member.builder().id(1L).role(Role.USER).build(); + Vote vote = Vote.builder().id(10L).member(member).build(); + + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(member)); + + voteService.deleteVote(1L, 10L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Vote.class); + verify(voteRepository).save(captor.capture()); + assertThat(captor.getValue().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("관리자는 다른 사용자의 투표를 삭제할 수 있다") + void 관리자_타인투표_삭제성공() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Member writer = Member.builder().id(2L).role(Role.USER).build(); + Vote vote = Vote.builder().id(10L).member(writer).build(); + + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(admin)); + + voteService.deleteVote(1L, 10L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Vote.class); + verify(voteRepository).save(captor.capture()); + assertThat(captor.getValue().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("일반 사용자가 다른 사람의 투표를 삭제하려 하면 403 예외가 발생한다") + void 일반사용자_타인투표_삭제실패() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + Member writer = Member.builder().id(2L).role(Role.USER).build(); + Vote vote = Vote.builder().id(10L).member(writer).build(); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(memberRepository.findByIdAndDeletedAtIsNull(any())).thenReturn(Optional.of(user)); + + ApiException ex = assertThrows(ApiException.class, () -> voteService.deleteVote(1L, 10L)); + assertThat(ex.getMessage()).isEqualTo("삭제 권한이 없습니다."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); + } - // when - ApiException apiException = assertThrows(ApiException.class, () -> voteService.deleteVote(1L, 10L)); + // ────────────────────────────────────────────── + // updatePinStatus + // ────────────────────────────────────────────── - // then - assertThat(apiException.getMessage()).isEqualTo("삭제 권한이 없습니다."); + @Test + @DisplayName("일반 사용자가 핀 설정을 시도하면 403 예외가 발생한다") + void 핀설정_권한없음() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + ApiException ex = assertThrows(ApiException.class, + () -> voteService.updatePinStatus(user, 10L, PinType.HOT)); + assertThat(ex.getMessage()).isEqualTo("권한이 없습니다."); + assertThat(ex.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); } + @Test + @DisplayName("관리자가 HOT 핀 설정 시 기존 HOT 게시물의 핀이 해제된다") + void 핀설정_기존핀해제() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Vote existingHot = Vote.builder().id(99L).build(); + existingHot.pin(PinType.HOT); + Vote newVote = Vote.builder().id(10L).build(); + when(voteRepository.findById(10L)).thenReturn(Optional.of(newVote)); + when(voteRepository.findByPinType(PinType.HOT)).thenReturn(Optional.of(existingHot)); + voteService.updatePinStatus(admin, 10L, PinType.HOT); + assertThat(existingHot.getPinType()).isEqualTo(PinType.NONE); // 기존 게시물 핀 해제 + assertThat(newVote.getPinType()).isEqualTo(PinType.HOT); // 새 게시물 핀 설정 + verify(voteRepository, times(2)).save(any(Vote.class)); + } + + @Test + @DisplayName("관리자가 NONE으로 설정하면 핀이 해제된다") + void 핀해제() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Vote vote = Vote.builder().id(10L).build(); + vote.pin(PinType.HOT); + + when(voteRepository.findById(10L)).thenReturn(Optional.of(vote)); + + voteService.updatePinStatus(admin, 10L, PinType.NONE); + + assertThat(vote.getPinType()).isEqualTo(PinType.NONE); + verify(voteRepository, times(1)).save(vote); + } } From 02a3bfd92f9cdd4326bc9fc9f3982d450612c538 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 28 Apr 2026 14:46:09 +0900 Subject: [PATCH 10/43] =?UTF-8?q?fix=20:=20=ED=95=AB=EC=9D=B4=EC=8A=88,=20?= =?UTF-8?q?=EA=B8=89=EC=83=81=EC=8A=B9=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/VoteRepositoryImpl.java | 66 +++++++++++++++++++ .../VoteRepositoryCustom.java | 6 +- .../service/VoteService/VoteServiceImpl.java | 36 +++------- 3 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java index fb14faa..3c0b7be 100644 --- a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java @@ -2,7 +2,9 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.valanse.valanse.domain.QCommentGroup; import com.valanse.valanse.domain.QVote; import com.valanse.valanse.domain.Vote; import com.valanse.valanse.domain.enums.VoteCategory; @@ -13,6 +15,11 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Optional; + +import static com.valanse.valanse.domain.QComment.comment; +import static com.valanse.valanse.domain.QVoteOption.voteOption; +import static com.valanse.valanse.domain.mapping.QMemberVoteOption.memberVoteOption; @Repository @RequiredArgsConstructor @@ -20,6 +27,7 @@ public class VoteRepositoryImpl implements VoteRepositoryCustom { private final JPAQueryFactory queryFactory; private final QVote vote = QVote.vote; + private final QCommentGroup commentGroup = QCommentGroup.commentGroup; @Override public List findVotesByCursor(String category, String sort, String cursor, int size) { @@ -62,4 +70,62 @@ public List findVotesByCursor(String category, String sort, String cursor, .limit(size + 1) // 다음 페이지 존재 여부 확인 .fetch(); } + + @Override + public Optional findHotIssueVote() { + return Optional.ofNullable( + queryFactory + .selectFrom(vote) + .leftJoin(vote.commentGroup, commentGroup) + .fetchJoin() + .orderBy( + vote.totalVoteCount + .add(commentGroup.totalCommentCount.coalesce(0)) + .desc(), + vote.createdAt.desc() // 점수 같을 때 최신순 + ) + .fetchFirst()); + } + + @Override + public Optional findTrendingVote(LocalDateTime from, LocalDateTime to) { + return Optional.ofNullable( + queryFactory + .selectFrom(vote) + .leftJoin(vote.commentGroup, commentGroup) + .fetchJoin() + .where( + // 기간 내 댓글 존재 + JPAExpressions + .selectOne() + .from(comment) + .where( + comment.commentGroup.eq(commentGroup), + comment.createdAt.between(from, to), + comment.deletedAt.isNull() + ) + .exists() + .or( + // 기간 내 투표 존재 + JPAExpressions + .selectOne() + .from(memberVoteOption) + .join(memberVoteOption.voteOption, voteOption) + .where( + voteOption.vote.eq(vote), + memberVoteOption.createdAt.between(from, to) + ) + .exists() + ) + ) + .orderBy( + vote.totalVoteCount + .add(commentGroup.totalCommentCount.coalesce(0)) + .desc(), + vote.createdAt.desc() // 점수 같을 때 최신순 + ) + .fetchFirst()); + + } + } \ No newline at end of file diff --git a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java index 9febbf7..26a931a 100644 --- a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java +++ b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java @@ -2,8 +2,12 @@ import com.valanse.valanse.domain.Vote; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface VoteRepositoryCustom { List findVotesByCursor(String category, String sort, String cursor, int size); -} \ No newline at end of file + Optional findHotIssueVote(); + Optional findTrendingVote(LocalDateTime from, LocalDateTime to); +} diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 6b3dad4..880d6ac 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -88,7 +88,7 @@ public List getMyVotedVotes(Long memberId, String sort, VoteCat //여기서부터 영서 코드 @Override - @Transactional +// @Transactional public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 // 0. 고정 게시물이 있다면 반환. Optional pinnedHot = voteRepository.findByPinType(PinType.HOT); @@ -101,24 +101,14 @@ public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 LocalDateTime now = LocalDateTime.now(); LocalDateTime yesterdayStart = now.minusDays(1).withHour(0).withMinute(0).withSecond(0); - // 1. 먼저 모든 투표의 반응성 점수를 업데이트 (실시간 계산) - List allVotes = voteRepository.findAll(); - for(Vote vote : allVotes) { - vote.updateReactivityScore(); // Vote 엔티티에 추가한 메서드 - voteRepository.save(vote); - } - - // 2. 작일 동안 반응성이 가장 높은 투표 조회 시도 - Optional yesterdayHotIssue = voteRepository - .findTopByCreatedAtBetweenOrderByReactivityScoreDescCreatedAtDesc(yesterdayStart, now); - + // 작일 동안 반응성이 가장 높은 투표 조회 시도 + Optional yesterdayHotIssue = voteRepository.findTrendingVote(yesterdayStart, now); Vote hotIssueVote; if (yesterdayHotIssue.isPresent()) { // 작일 반응성 데이터가 있는 경우 hotIssueVote = yesterdayHotIssue.get(); } else { - // 작일 반응성 데이터가 없는 경우 → 전체 누적 반응성 기준 조회 - hotIssueVote = voteRepository.findTopByOrderByReactivityScoreDescCreatedAtDesc() + hotIssueVote = voteRepository.findHotIssueVote() .orElseThrow(() -> new ApiException("핫이슈 투표를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); } @@ -140,19 +130,13 @@ public HotIssueVoteResponse getTrendingVote() { } - // 7일 이전 시간 계산 - LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); - - // 1. 모든 투표의 반응성 점수 업데이트 - List allVotes = voteRepository.findAll(); - for(Vote vote : allVotes) { - vote.updateReactivityScore(); - voteRepository.save(vote); - } + // 7일 이전 시간 계산 + LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); + LocalDateTime now = LocalDateTime.now(); - // 2. 최근 7일 내 반응성이 가장 높은 투표 조회 + // 최근 7일 내 반응성이 가장 높은 투표 조회 Optional recentTrendingVote = voteRepository - .findTopByCreatedAtBetweenOrderByReactivityScoreDescCreatedAtDesc(sevenDaysAgo, LocalDateTime.now()); + .findTrendingVote(sevenDaysAgo, now); Vote trendingVote; if (recentTrendingVote.isPresent()) { @@ -160,7 +144,7 @@ public HotIssueVoteResponse getTrendingVote() { trendingVote = recentTrendingVote.get(); } else { // 7일 내 데이터가 없는 경우 - 이전 데이터 유지 (전체 기간에서 가장 높은 반응성) - trendingVote = voteRepository.findTopByOrderByReactivityScoreDescCreatedAtDesc() + trendingVote = voteRepository.findHotIssueVote() .orElseThrow(() -> new ApiException("인기 급상승 투표를 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); } From f2b09b6e90893b0e197cfc7581b52a83cdd3e3b7 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 29 Apr 2026 13:07:45 +0900 Subject: [PATCH 11/43] =?UTF-8?q?fix:=20getHotIssueVote()=20=20fetchJoin?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/valanse/valanse/repository/VoteRepositoryImpl.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java index 3c0b7be..ea01274 100644 --- a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java @@ -77,7 +77,6 @@ public Optional findHotIssueVote() { queryFactory .selectFrom(vote) .leftJoin(vote.commentGroup, commentGroup) - .fetchJoin() .orderBy( vote.totalVoteCount .add(commentGroup.totalCommentCount.coalesce(0)) @@ -93,7 +92,6 @@ public Optional findTrendingVote(LocalDateTime from, LocalDateTime to) { queryFactory .selectFrom(vote) .leftJoin(vote.commentGroup, commentGroup) - .fetchJoin() .where( // 기간 내 댓글 존재 JPAExpressions From 4d772078a3d25f49e9902d3b20f5dac0f6743801 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 29 Apr 2026 13:08:53 +0900 Subject: [PATCH 12/43] =?UTF-8?q?fix=20:=20getTrendingVote()=20@Transactio?= =?UTF-8?q?nal=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/valanse/valanse/service/VoteService/VoteServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 880d6ac..715f679 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -118,7 +118,6 @@ public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 // 인기 급상승 토픽 @Override - @Transactional public HotIssueVoteResponse getTrendingVote() { // 0. 고정 게시물이 있다면 반환. Optional pinnedTrending = voteRepository.findByPinType(PinType.TRENDING); From d2c0b56bad1b99f2a7c5524b8173a5234846149d Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 29 Apr 2026 13:09:56 +0900 Subject: [PATCH 13/43] =?UTF-8?q?fix:=20getHotissueVote()=20@Transactional?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/valanse/valanse/service/VoteService/VoteServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 715f679..84d443a 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -88,7 +88,6 @@ public List getMyVotedVotes(Long memberId, String sort, VoteCat //여기서부터 영서 코드 @Override -// @Transactional public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 // 0. 고정 게시물이 있다면 반환. Optional pinnedHot = voteRepository.findByPinType(PinType.HOT); From 951956a7be16d8e079dff33de17c38b0b7720e19 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 29 Apr 2026 13:14:04 +0900 Subject: [PATCH 14/43] =?UTF-8?q?fix=20:=20getVoteDetailById()=20System.ou?= =?UTF-8?q?t.println=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/service/VoteService/VoteServiceImpl.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 84d443a..43ad343 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -282,16 +282,8 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { SecurityContextHolder.getContext().getAuthentication().getName() != null && !SecurityContextHolder.getContext().getAuthentication().getName().equals("anonymousUser")) { currentUserId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); - // --- 디버깅 로그 추가 시작 --- - System.out.println("DEBUG: Authenticated user ID: " + currentUserId); - // --- 디버깅 로그 추가 끝 --- - } else { - // --- 디버깅 로그 추가 시작 --- - System.out.println("DEBUG: User is not authenticated or is anonymous."); - // --- 디버깅 로그 추가 끝 --- } } catch (NumberFormatException e) { - System.out.println("DEBUG: Error parsing user ID from SecurityContext: " + e.getMessage()); currentUserId = null; } From 0acb9e40df856025621a01300831c0deb96dc005 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 29 Apr 2026 13:14:44 +0900 Subject: [PATCH 15/43] =?UTF-8?q?fix=20:=20processVote()=20=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=86=EB=8A=94=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/valanse/valanse/service/VoteService/VoteServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 43ad343..9983d45 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -165,7 +165,6 @@ public VoteCancleResponseDto processVote(Long userId, Long voteId, Long voteOpti .orElseThrow(() -> new ApiException("투표 선택지가 존재하지 않습니다.", HttpStatus.NOT_FOUND)); // 2. 사용자가 이 투표에 대해 이전에 투표한 선택지가 있는지 확인합니다. - // ERD의 member_vote_option 테이블을 활용 [cite: image_02691a.jpg] Optional existingVote = memberVoteOptionRepository.findByMemberIdAndVoteId(userId, voteId); boolean isVoted; // 최종적으로 투표가 되어 있는지 여부 (응답 DTO에 사용) From 1813577f2afe8130211055ec1b4a291ca0de61ff Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 29 Apr 2026 13:16:51 +0900 Subject: [PATCH 16/43] =?UTF-8?q?fix=20:=20processVote()=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=86=EB=8A=94=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/service/VoteService/VoteServiceImpl.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 9983d45..1e8d8df 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -178,11 +178,11 @@ public VoteCancleResponseDto processVote(Long userId, Long voteId, Long voteOpti if (oldVoteOption.getId().equals(voteOptionId)) { // 3-1. 동일한 선택지를 다시 클릭한 경우: 투표 취소 - // member_vote_option에서 해당 기록을 삭제 [cite: image_0268de.png] + // member_vote_option에서 해당 기록을 삭제 memberVoteOptionRepository.delete(oldMemberVoteOption); - // 기존 선택지의 투표 수 감소 [cite: image_0268de.png] + // 기존 선택지의 투표 수 감소 oldVoteOption.setVoteCount(oldVoteOption.getVoteCount() - 1); - // 전체 투표 수 감소 [cite: image_0268de.png] + // 전체 투표 수 감소a vote.setTotalVoteCount(vote.getTotalVoteCount() - 1); isVoted = false; // 투표가 취소되었으므로 false From 96cee2abe077a4b0f85ab66f9575c4a8724c7524 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Thu, 7 May 2026 10:37:38 +0900 Subject: [PATCH 17/43] chore: test dev deployment From e182804e04d3a410713ea3e0ff81918dc3d9c0d2 Mon Sep 17 00:00:00 2001 From: 5solbin <122352841+5solbin@users.noreply.github.com> Date: Thu, 7 May 2026 13:35:39 +0900 Subject: [PATCH 18/43] Update deploy-dev.yml to include AWS env variables Add environment variables for AWS credentials in SSH step. --- .github/workflows/deploy-dev.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 2630afa..4d2692d 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -48,11 +48,16 @@ jobs: - name: SSH로 EC2에 접속하여 Development 서버 재배포 uses: appleboy/ssh-action@v1.0.3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ap-northeast-2 with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_PRIVATE_KEY }} script_stop: true + envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_DEFAULT_REGION script: | cd ~/valanse @@ -66,4 +71,4 @@ jobs: docker compose up -d valanse-dev valanse-dev-server # 사용하지 않는 이미지 정리 - docker image prune -f \ No newline at end of file + docker image prune -f From 0c00af1e7d9fa29cf9e4148923434d35492d10ec Mon Sep 17 00:00:00 2001 From: 5solbin Date: Thu, 7 May 2026 15:20:46 +0900 Subject: [PATCH 19/43] chore: retry dev deployment From fc39bb6f20b4c5bbcf8e37449edce477ae795d95 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Thu, 7 May 2026 16:40:44 +0900 Subject: [PATCH 20/43] chore: retry dev deployment on new aws account From 36764cc4b787153461232a14e6e948215ea18bb1 Mon Sep 17 00:00:00 2001 From: 5solbin <122352841+5solbin@users.noreply.github.com> Date: Mon, 11 May 2026 15:01:38 +0900 Subject: [PATCH 21/43] =?UTF-8?q?fix=20:=20SwaggerConfig=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/valanse/valanse/common/config/SwaggerConfig.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/valanse/valanse/common/config/SwaggerConfig.java b/src/main/java/com/valanse/valanse/common/config/SwaggerConfig.java index ad43c0a..ff18541 100644 --- a/src/main/java/com/valanse/valanse/common/config/SwaggerConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SwaggerConfig.java @@ -13,9 +13,9 @@ @OpenAPIDefinition( servers = { @Server(url = "http://localhost:8080", description = "로컬 개발 서버"), - @Server(url = "http://backendbase.store:8080", description = "운영 서버 (prod)"), - @Server(url = "http://backendbase.store:8081", description = "개발 서버 (dev)"), - @Server(url = "https://backendbase.store", description = "HTTPS 배포 서버") + @Server(url = "http://valanserver.store:8080", description = "운영 서버 (prod)"), + @Server(url = "http://valanserver.store:8081", description = "개발 서버 (dev)"), + @Server(url = "https://valanserver.store", description = "HTTPS 배포 서버") } ) @Configuration From dd3f41cf01edbbf84f4484124f22fe318f4ace1d Mon Sep 17 00:00:00 2001 From: 5solbin Date: Mon, 11 May 2026 16:10:09 +0900 Subject: [PATCH 22/43] =?UTF-8?q?fix=20:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20security=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/valanse/common/config/SecurityConfig.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index 34a2e8a..a5a2acf 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -107,7 +107,14 @@ public CorsConfigurationSource corsConfigurationSource() { "http://backendbase.store:8082", "https://backendbase.store:8080", "https://backendbase.store:8081", - "https://backendbase.store:8082" + "https://backendbase.store:8082", + "http://valanserver.store", + "http://valanserver.store:8080", + "http://valanserver.store:8081", + "http://valanserver.store:8082", + "https://valanserver.store:8080", + "https://valanserver.store:8081", + "https://valanserver.store:8082" )); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); From 028d3a71674781d2c68a13653c40ef704307e524 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 12 May 2026 13:43:27 +0900 Subject: [PATCH 23/43] =?UTF-8?q?feat:=20=EC=B9=AD=ED=98=B8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/valanse/domain/MemberProfile.java | 21 ++++++ .../valanse/domain/MemberProfileTitle.java | 59 +++++++++++++++ .../com/valanse/valanse/domain/Title.java | 73 +++++++++++++++++++ .../domain/enums/TitleAcquisitionType.java | 9 +++ .../valanse/domain/enums/TitleTier.java | 10 +++ 5 files changed, 172 insertions(+) create mode 100644 src/main/java/com/valanse/valanse/domain/MemberProfileTitle.java create mode 100644 src/main/java/com/valanse/valanse/domain/Title.java create mode 100644 src/main/java/com/valanse/valanse/domain/enums/TitleAcquisitionType.java create mode 100644 src/main/java/com/valanse/valanse/domain/enums/TitleTier.java diff --git a/src/main/java/com/valanse/valanse/domain/MemberProfile.java b/src/main/java/com/valanse/valanse/domain/MemberProfile.java index ceb5548..7cafdd8 100644 --- a/src/main/java/com/valanse/valanse/domain/MemberProfile.java +++ b/src/main/java/com/valanse/valanse/domain/MemberProfile.java @@ -11,6 +11,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.ArrayList; +import java.util.List; + @Builder @AllArgsConstructor @NoArgsConstructor @@ -46,6 +49,10 @@ public class MemberProfile extends BaseEntity { @Builder.Default private long point = 0L; + @Builder.Default + @OneToMany(mappedBy = "memberProfile", cascade = CascadeType.ALL) + private List memberProfileTitles = new ArrayList<>(); + public void update(String nickname, Gender gender, Age age, MbtiIe mbtiIe, MbtiTf mbtiTf, String mbti) { this.nickname = nickname; this.gender = gender; @@ -58,4 +65,18 @@ public void update(String nickname, Gender gender, Age age, MbtiIe mbtiIe, MbtiT public void addPoint(long amount) { this.point += amount; } + + public boolean hasEnoughPoint(long amount) { + return this.point >= amount; + } + + public void subtractPoint(long amount) { + if (amount < 0) { + throw new IllegalArgumentException("차감할 포인트는 0 이상이어야 합니다."); + } + if (!hasEnoughPoint(amount)) { + throw new IllegalArgumentException("포인트가 부족합니다."); + } + this.point -= amount; + } } diff --git a/src/main/java/com/valanse/valanse/domain/MemberProfileTitle.java b/src/main/java/com/valanse/valanse/domain/MemberProfileTitle.java new file mode 100644 index 0000000..5cd0039 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/MemberProfileTitle.java @@ -0,0 +1,59 @@ +package com.valanse.valanse.domain; + +import com.valanse.valanse.domain.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Entity +public class MemberProfileTitle extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_profile_id", nullable = false) + private MemberProfile memberProfile; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "title_id", nullable = false) + private Title title; + + @Builder.Default + @Column(nullable = false) + private boolean equipped = false; + + private LocalDateTime acquiredAt; + + @PrePersist + public void prePersist() { + if (acquiredAt == null) { + acquiredAt = LocalDateTime.now(); + } + } + + public void equip() { + this.equipped = true; + } + + public void unequip() { + this.equipped = false; + } +} diff --git a/src/main/java/com/valanse/valanse/domain/Title.java b/src/main/java/com/valanse/valanse/domain/Title.java new file mode 100644 index 0000000..c6d5d18 --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/Title.java @@ -0,0 +1,73 @@ +package com.valanse.valanse.domain; + +import com.valanse.valanse.domain.common.BaseEntity; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Entity +public class Title extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String code; + + @Column(nullable = false) + private String name; + + private String description; + + @Builder.Default + private long price = 0L; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TitleTier tier; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TitleAcquisitionType acquisitionType; + + private String requirementText; + + private String colorHex; + + @Builder.Default + private boolean active = true; + + @Builder.Default + private int displayOrder = 0; + + public boolean isDefaultTitle() { + return acquisitionType == TitleAcquisitionType.DEFAULT; + } + + public boolean isPointPurchaseTitle() { + return acquisitionType == TitleAcquisitionType.POINT_PURCHASE; + } + + public boolean isPurchasable(long point) { + return active && isPointPurchaseTitle() && point >= price; + } + + public void deactivate() { + this.active = false; + } +} diff --git a/src/main/java/com/valanse/valanse/domain/enums/TitleAcquisitionType.java b/src/main/java/com/valanse/valanse/domain/enums/TitleAcquisitionType.java new file mode 100644 index 0000000..c38578f --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/enums/TitleAcquisitionType.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.domain.enums; + +public enum TitleAcquisitionType { + DEFAULT, + POINT_PURCHASE, + ACHIEVEMENT, + SEASON, + EVENT +} diff --git a/src/main/java/com/valanse/valanse/domain/enums/TitleTier.java b/src/main/java/com/valanse/valanse/domain/enums/TitleTier.java new file mode 100644 index 0000000..5843c6a --- /dev/null +++ b/src/main/java/com/valanse/valanse/domain/enums/TitleTier.java @@ -0,0 +1,10 @@ +package com.valanse.valanse.domain.enums; + +public enum TitleTier { + BASIC, + TIER_1, + TIER_2, + TIER_3, + SEASON, + RARE +} From b7c52b9f1d03f993adf2feb3d818098462bbf24b Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 12 May 2026 14:48:32 +0900 Subject: [PATCH 24/43] =?UTF-8?q?feat:=20=EC=B9=AD=ED=98=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/valanse/repository/TitleRepository.java | 9 +++++++++ .../valanse/service/TitleService/TitleService.java | 4 ++++ .../service/TitleService/TitleServiceImpl.java | 11 +++++++++++ 3 files changed, 24 insertions(+) create mode 100644 src/main/java/com/valanse/valanse/repository/TitleRepository.java create mode 100644 src/main/java/com/valanse/valanse/service/TitleService/TitleService.java create mode 100644 src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java diff --git a/src/main/java/com/valanse/valanse/repository/TitleRepository.java b/src/main/java/com/valanse/valanse/repository/TitleRepository.java new file mode 100644 index 0000000..62a147e --- /dev/null +++ b/src/main/java/com/valanse/valanse/repository/TitleRepository.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.repository; + +import com.valanse.valanse.domain.Title; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TitleRepository extends JpaRepository { +} diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java new file mode 100644 index 0000000..acf1e36 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -0,0 +1,4 @@ +package com.valanse.valanse.service.TitleService; + +public interface TitleService { +} diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java new file mode 100644 index 0000000..6275b41 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -0,0 +1,11 @@ +package com.valanse.valanse.service.TitleService; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class TitleServiceImpl implements TitleService { +} From 9c7bff84234a4c661451d913721ab69cf1033bb6 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 12 May 2026 14:58:12 +0900 Subject: [PATCH 25/43] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=9E=A5=EC=B0=A9=20=EC=B9=AD?= =?UTF-8?q?=ED=98=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberProfile/MemberMyPageResponse.java | 1 + .../MemberProfile/MemberProfileResponse.java | 1 + .../MemberProfileServiceImpl.java | 13 +++++++++++ .../MemberProfileServiceImplTest.java | 23 +++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java index 6d8effc..b81f768 100644 --- a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java +++ b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberMyPageResponse.java @@ -11,6 +11,7 @@ public record MyPageInfo( String gender, String age, String mbti, + String title, long point ){} } diff --git a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java index 947d044..c64b2bc 100644 --- a/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java +++ b/src/main/java/com/valanse/valanse/dto/MemberProfile/MemberProfileResponse.java @@ -13,6 +13,7 @@ public record Info( String mbtiTf, String mbti, Role role, + String title, long point ) {} } diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java index 9103ea2..32cee41 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java @@ -2,6 +2,7 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.MemberProfileTitle; import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; @@ -106,6 +107,7 @@ public MemberProfileResponse getProfile() { profile.getMbtiTf() != null ? profile.getMbtiTf().name() : null, profile.getMbti() != null ? profile.getMbti() : null, member.getRole() != null ? member.getRole() : null, + getEquippedTitleName(profile), profile.getPoint() ); @@ -217,10 +219,21 @@ public MemberMyPageResponse getMyProfile() { profile.getGender() != null ? profile.getGender().name() : null, profile.getAge() != null ? profile.getAge().name() : null, profile.getMbti() != null ? profile.getMbti() : null, + getEquippedTitleName(profile), profile.getPoint() ); return new MemberMyPageResponse(info); } + private String getEquippedTitleName(MemberProfile profile) { + return profile.getMemberProfileTitles().stream() + .filter(MemberProfileTitle::isEquipped) + .map(MemberProfileTitle::getTitle) + .filter(title -> title != null) + .map(title -> title.getName()) + .findFirst() + .orElse(null); + } + } diff --git a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java index fbe6107..4db001d 100644 --- a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java @@ -2,6 +2,8 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; import com.valanse.valanse.domain.enums.*; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.repository.MemberProfileRepository; @@ -313,6 +315,7 @@ void setupSecurityContext() { .mbti("ENTP") .point(100L) .build(); + profile.getMemberProfileTitles().add(equippedProfileTitle(profile, "균형의 달인")); when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); @@ -325,6 +328,7 @@ void setupSecurityContext() { assertThat(response.profile()).isNotNull(); assertThat(response.profile().point()).isEqualTo(100L); assertThat(response.profile().nickname()).isEqualTo("테스트닉네임"); + assertThat(response.profile().title()).isEqualTo("균형의 달인"); } @Test @@ -341,6 +345,7 @@ void setupSecurityContext() { .mbti("ENTP") .point(150L) .build(); + profile.getMemberProfileTitles().add(equippedProfileTitle(profile, "선택의 신")); when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); @@ -353,5 +358,23 @@ void setupSecurityContext() { assertThat(response.profile()).isNotNull(); assertThat(response.profile().point()).isEqualTo(150L); assertThat(response.profile().nickname()).isEqualTo("테스트닉네임"); + assertThat(response.profile().title()).isEqualTo("선택의 신"); + } + + private MemberProfileTitle equippedProfileTitle(MemberProfile profile, String titleName) { + Title title = Title.builder() + .code(titleName) + .name(titleName) + .tier(TitleTier.BASIC) + .acquisitionType(TitleAcquisitionType.DEFAULT) + .build(); + + MemberProfileTitle profileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build(); + profileTitle.equip(); + + return profileTitle; } } From b852502adeccbef710e39932a002a29a0cea4569 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Tue, 12 May 2026 22:34:42 +0900 Subject: [PATCH 26/43] =?UTF-8?q?feat:=20=EC=B9=AD=ED=98=B8=20=EC=9E=A5?= =?UTF-8?q?=EC=B0=A9=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/dto/Title/TitleEquipResponse.java | 8 ++ .../MemberProfileTitleRepository.java | 13 ++ .../service/TitleService/TitleService.java | 3 + .../TitleService/TitleServiceImpl.java | 27 ++++ .../TitleService/TitleServiceImplTest.java | 128 ++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleEquipResponse.java create mode 100644 src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java create mode 100644 src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleEquipResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleEquipResponse.java new file mode 100644 index 0000000..0cebb9e --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleEquipResponse.java @@ -0,0 +1,8 @@ +package com.valanse.valanse.dto.Title; + +public record TitleEquipResponse( + Long titleId, + String title, + boolean equipped +) { +} diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java new file mode 100644 index 0000000..33a2913 --- /dev/null +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java @@ -0,0 +1,13 @@ +package com.valanse.valanse.repository; + +import com.valanse.valanse.domain.MemberProfileTitle; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MemberProfileTitleRepository extends JpaRepository { + Optional findByMemberProfileMemberIdAndTitleId(Long memberId, Long titleId); + + List findAllByMemberProfileMemberIdAndEquippedTrue(Long memberId); +} diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java index acf1e36..4fb49bf 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -1,4 +1,7 @@ package com.valanse.valanse.service.TitleService; +import com.valanse.valanse.dto.Title.TitleEquipResponse; + public interface TitleService { + TitleEquipResponse equipTitle(Long userId, Long titleId); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 6275b41..4770e9a 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -1,5 +1,9 @@ package com.valanse.valanse.service.TitleService; +import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.dto.Title.TitleEquipResponse; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -8,4 +12,27 @@ @RequiredArgsConstructor @Transactional public class TitleServiceImpl implements TitleService { + + private final MemberProfileRepository memberProfileRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; + + @Override + public TitleEquipResponse equipTitle(Long userId, Long titleId) { + memberProfileRepository.findByMemberId(userId) + .orElseThrow(() -> new IllegalArgumentException("프로필이 존재하지 않습니다.")); + + MemberProfileTitle targetTitle = memberProfileTitleRepository + .findByMemberProfileMemberIdAndTitleId(userId, titleId) + .orElseThrow(() -> new IllegalArgumentException("보유하지 않은 칭호입니다.")); + + memberProfileTitleRepository.findAllByMemberProfileMemberIdAndEquippedTrue(userId) + .forEach(MemberProfileTitle::unequip); + targetTitle.equip(); + + return new TitleEquipResponse( + targetTitle.getTitle().getId(), + targetTitle.getTitle().getName(), + targetTitle.isEquipped() + ); + } } diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java new file mode 100644 index 0000000..795770d --- /dev/null +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -0,0 +1,128 @@ +package com.valanse.valanse.service.TitleService; + +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TitleServiceImplTest { + + @InjectMocks + private TitleServiceImpl titleService; + + @Mock + private MemberProfileRepository memberProfileRepository; + + @Mock + private MemberProfileTitleRepository memberProfileTitleRepository; + + private Member member; + private MemberProfile profile; + + @BeforeEach + void setup() { + member = Member.builder() + .id(1L) + .email("test@email.com") + .nickname("테스터") + .name("test") + .build(); + profile = MemberProfile.builder() + .member(member) + .nickname("테스트닉네임") + .build(); + } + + @Test + @DisplayName("equipTitle()은 기존 장착 칭호를 해제하고 선택한 칭호를 장착한다") + void equipTitle_기존칭호해제_선택칭호장착() { + MemberProfileTitle equippedTitle = equippedProfileTitle(1L, "기존 칭호"); + MemberProfileTitle targetTitle = profileTitle(2L, "새 칭호"); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 2L)) + .thenReturn(Optional.of(targetTitle)); + when(memberProfileTitleRepository.findAllByMemberProfileMemberIdAndEquippedTrue(1L)) + .thenReturn(List.of(equippedTitle)); + + var response = titleService.equipTitle(1L, 2L); + + assertThat(equippedTitle.isEquipped()).isFalse(); + assertThat(targetTitle.isEquipped()).isTrue(); + assertThat(response.titleId()).isEqualTo(2L); + assertThat(response.title()).isEqualTo("새 칭호"); + assertThat(response.equipped()).isTrue(); + } + + @Test + @DisplayName("equipTitle()은 보유하지 않은 칭호를 장착할 수 없다") + void equipTitle_보유하지않은칭호_예외() { + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 99L)) + .thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.equipTitle(1L, 99L) + ); + + assertThat(exception.getMessage()).isEqualTo("보유하지 않은 칭호입니다."); + verify(memberProfileTitleRepository, never()).findAllByMemberProfileMemberIdAndEquippedTrue(1L); + } + + @Test + @DisplayName("equipTitle()은 프로필이 없으면 예외를 던진다") + void equipTitle_프로필없음_예외() { + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.equipTitle(1L, 1L) + ); + + assertThat(exception.getMessage()).isEqualTo("프로필이 존재하지 않습니다."); + verify(memberProfileTitleRepository, never()).findByMemberProfileMemberIdAndTitleId(1L, 1L); + } + + private MemberProfileTitle equippedProfileTitle(Long titleId, String titleName) { + MemberProfileTitle profileTitle = profileTitle(titleId, titleName); + profileTitle.equip(); + return profileTitle; + } + + private MemberProfileTitle profileTitle(Long titleId, String titleName) { + Title title = Title.builder() + .id(titleId) + .code("TITLE_" + titleId) + .name(titleName) + .tier(TitleTier.BASIC) + .acquisitionType(TitleAcquisitionType.DEFAULT) + .build(); + + return MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build(); + } +} From 5f5a564ea5a8ce6e95e83079e986c804ebc4b9b8 Mon Sep 17 00:00:00 2001 From: 5solbin Date: Wed, 13 May 2026 17:27:47 +0900 Subject: [PATCH 27/43] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=B9=AD?= =?UTF-8?q?=ED=98=B8=20=EB=AA=A9=EB=A1=9D=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/controller/MemberController.java | 26 +++++ .../valanse/dto/Title/TitleListResponse.java | 28 ++++++ .../MemberProfileTitleRepository.java | 2 + .../valanse/repository/TitleRepository.java | 3 + .../service/TitleService/TitleService.java | 3 + .../TitleService/TitleServiceImpl.java | 93 ++++++++++++++++++ .../TitleService/TitleServiceImplTest.java | 97 +++++++++++++++++-- 7 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index 5b9bbdf..10e34c4 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -4,8 +4,11 @@ import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.dto.Title.TitleEquipResponse; +import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.service.MemberProfileService.MemberProfileService; import com.valanse.valanse.service.PointService.PointService; +import com.valanse.valanse.service.TitleService.TitleService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -24,6 +27,7 @@ public class MemberController { private final MemberProfileService memberProfileService; private final PointService pointService; + private final TitleService titleService; @Operation( summary = "회원 프로필 정보 저장", @@ -107,4 +111,26 @@ public ResponseEntity getPointHistory() { PointHistoryResponse response = pointService.getPointHistory(userId); return ResponseEntity.ok(response); } + + @Operation( + summary = "칭호 선택 목록 조회", + description = "현재 로그인한 회원 기준으로 기본, 보유, 미보유 칭호를 분리해서 조회합니다." + ) + @GetMapping("/titles") + public ResponseEntity getTitles() { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleListResponse response = titleService.getTitleList(userId); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "칭호 장착", + description = "현재 로그인한 회원이 보유한 칭호를 대표 칭호로 선택합니다." + ) + @PostMapping("/titles/{titleId}/equip") + public ResponseEntity equipTitle(@PathVariable Long titleId) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleEquipResponse response = titleService.equipTitle(userId, titleId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java new file mode 100644 index 0000000..904f179 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java @@ -0,0 +1,28 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +import java.util.List; + +public record TitleListResponse( + List defaultTitles, + List ownedTitles, + List lockedTitles +) { + public record TitleSummaryResponse( + Long titleId, + String title, + String description, + TitleTier tier, + TitleAcquisitionType acquisitionType, + boolean owned, + boolean equipped, + boolean locked, + Long price, + String requirementText, + String lockReason, + String colorHex + ) { + } +} diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java index 33a2913..ca10fd0 100644 --- a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java @@ -10,4 +10,6 @@ public interface MemberProfileTitleRepository extends JpaRepository findByMemberProfileMemberIdAndTitleId(Long memberId, Long titleId); List findAllByMemberProfileMemberIdAndEquippedTrue(Long memberId); + + List findAllByMemberProfileMemberId(Long memberId); } diff --git a/src/main/java/com/valanse/valanse/repository/TitleRepository.java b/src/main/java/com/valanse/valanse/repository/TitleRepository.java index 62a147e..10c559a 100644 --- a/src/main/java/com/valanse/valanse/repository/TitleRepository.java +++ b/src/main/java/com/valanse/valanse/repository/TitleRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface TitleRepository extends JpaRepository { + List findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java index 4fb49bf..099c06b 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -1,7 +1,10 @@ package com.valanse.valanse.service.TitleService; import com.valanse.valanse.dto.Title.TitleEquipResponse; +import com.valanse.valanse.dto.Title.TitleListResponse; public interface TitleService { + TitleListResponse getTitleList(Long userId); + TitleEquipResponse equipTitle(Long userId, Long titleId); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 4770e9a..360fc9c 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -1,13 +1,23 @@ package com.valanse.valanse.service.TitleService; +import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; import com.valanse.valanse.dto.Title.TitleEquipResponse; +import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; +import com.valanse.valanse.repository.TitleRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor @Transactional @@ -15,6 +25,57 @@ public class TitleServiceImpl implements TitleService { private final MemberProfileRepository memberProfileRepository; private final MemberProfileTitleRepository memberProfileTitleRepository; + private final TitleRepository titleRepository; + + @Override + public TitleListResponse getTitleList(Long userId) { + MemberProfile profile = memberProfileRepository.findByMemberId(userId) + .orElseThrow(() -> new IllegalArgumentException("프로필이 존재하지 않습니다.")); + + List<Title> titles = titleRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); + List<MemberProfileTitle> profileTitles = memberProfileTitleRepository.findAllByMemberProfileMemberId(userId); + Map<Long, MemberProfileTitle> ownedTitleMap = profileTitles.stream() + .collect(Collectors.toMap( + profileTitle -> profileTitle.getTitle().getId(), + Function.identity(), + (current, ignored) -> current + )); + + List<MemberProfileTitle> defaultTitlesToSave = titles.stream() + .filter(Title::isDefaultTitle) + .filter(title -> !ownedTitleMap.containsKey(title.getId())) + .map(title -> MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build()) + .toList(); + + if (!defaultTitlesToSave.isEmpty()) { + memberProfileTitleRepository.saveAll(defaultTitlesToSave) + .forEach(profileTitle -> ownedTitleMap.put(profileTitle.getTitle().getId(), profileTitle)); + } + + List<TitleListResponse.TitleSummaryResponse> defaultTitles = new ArrayList<>(); + List<TitleListResponse.TitleSummaryResponse> ownedTitles = new ArrayList<>(); + List<TitleListResponse.TitleSummaryResponse> lockedTitles = new ArrayList<>(); + + for (Title title : titles) { + MemberProfileTitle profileTitle = ownedTitleMap.get(title.getId()); + boolean owned = profileTitle != null || title.isDefaultTitle(); + boolean locked = !owned; + + TitleListResponse.TitleSummaryResponse response = toTitleSummary(title, profileTitle, owned, locked); + if (title.isDefaultTitle()) { + defaultTitles.add(response); + } else if (owned) { + ownedTitles.add(response); + } else { + lockedTitles.add(response); + } + } + + return new TitleListResponse(defaultTitles, ownedTitles, lockedTitles); + } @Override public TitleEquipResponse equipTitle(Long userId, Long titleId) { @@ -35,4 +96,36 @@ public TitleEquipResponse equipTitle(Long userId, Long titleId) { targetTitle.isEquipped() ); } + + private TitleListResponse.TitleSummaryResponse toTitleSummary( + Title title, + MemberProfileTitle profileTitle, + boolean owned, + boolean locked + ) { + return new TitleListResponse.TitleSummaryResponse( + title.getId(), + title.getName(), + title.getDescription(), + title.getTier(), + title.getAcquisitionType(), + owned, + profileTitle != null && profileTitle.isEquipped(), + locked, + title.getPrice(), + title.getRequirementText(), + locked ? getLockReason(title) : null, + title.getColorHex() + ); + } + + private String getLockReason(Title title) { + if (title.getRequirementText() != null && !title.getRequirementText().isBlank()) { + return title.getRequirementText(); + } + if (title.isPointPurchaseTitle()) { + return title.getPrice() + "P 필요"; + } + return "획득 조건을 달성해야 합니다."; + } } diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java index 795770d..41375d3 100644 --- a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -8,6 +8,7 @@ import com.valanse.valanse.domain.enums.TitleTier; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; +import com.valanse.valanse.repository.TitleRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -37,6 +38,9 @@ class TitleServiceImplTest { @Mock private MemberProfileTitleRepository memberProfileTitleRepository; + @Mock + private TitleRepository titleRepository; + private Member member; private MemberProfile profile; @@ -54,6 +58,65 @@ void setup() { .build(); } + @Test + @DisplayName("getTitleList()는 기본, 보유, 미보유 칭호를 분리해서 반환한다") + void getTitleList_기본_보유_미보유_분리() { + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + Title ownedTitle = title(2L, "싸움 구경꾼", TitleAcquisitionType.ACHIEVEMENT, 0L, "투표 10회 참여"); + Title pointTitle = title(3L, "선택의 신", TitleAcquisitionType.POINT_PURCHASE, 300L, null); + Title seasonTitle = title(4L, "2026 봄 논쟁왕", TitleAcquisitionType.SEASON, 0L, "시즌한정"); + MemberProfileTitle ownedProfileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(ownedTitle) + .build(); + ownedProfileTitle.equip(); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc()) + .thenReturn(List.of(defaultTitle, ownedTitle, pointTitle, seasonTitle)); + when(memberProfileTitleRepository.findAllByMemberProfileMemberId(1L)) + .thenReturn(List.of(ownedProfileTitle)); + when(memberProfileTitleRepository.saveAll(org.mockito.ArgumentMatchers.anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var response = titleService.getTitleList(1L); + + assertThat(response.defaultTitles()).hasSize(1); + assertThat(response.defaultTitles().get(0).title()).isEqualTo("밸런스 새싹"); + assertThat(response.defaultTitles().get(0).owned()).isTrue(); + assertThat(response.defaultTitles().get(0).locked()).isFalse(); + + assertThat(response.ownedTitles()).hasSize(1); + assertThat(response.ownedTitles().get(0).title()).isEqualTo("싸움 구경꾼"); + assertThat(response.ownedTitles().get(0).equipped()).isTrue(); + + assertThat(response.lockedTitles()).hasSize(2); + assertThat(response.lockedTitles().get(0).title()).isEqualTo("선택의 신"); + assertThat(response.lockedTitles().get(0).lockReason()).isEqualTo("300P 필요"); + assertThat(response.lockedTitles().get(1).title()).isEqualTo("2026 봄 논쟁왕"); + assertThat(response.lockedTitles().get(1).lockReason()).isEqualTo("시즌한정"); + } + + @Test + @DisplayName("getTitleList()는 누락된 기본 칭호를 자동 지급한다") + void getTitleList_기본칭호_자동지급() { + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc()).thenReturn(List.of(defaultTitle)); + when(memberProfileTitleRepository.findAllByMemberProfileMemberId(1L)).thenReturn(List.of()); + when(memberProfileTitleRepository.saveAll(org.mockito.ArgumentMatchers.anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var response = titleService.getTitleList(1L); + + assertThat(response.defaultTitles()).hasSize(1); + assertThat(response.defaultTitles().get(0).owned()).isTrue(); + verify(memberProfileTitleRepository).saveAll(org.mockito.ArgumentMatchers.argThat(savedTitles -> + containsOnlyTitle(savedTitles, defaultTitle.getId()) + )); + } + @Test @DisplayName("equipTitle()은 기존 장착 칭호를 해제하고 선택한 칭호를 장착한다") void equipTitle_기존칭호해제_선택칭호장착() { @@ -112,17 +175,39 @@ private MemberProfileTitle equippedProfileTitle(Long titleId, String titleName) } private MemberProfileTitle profileTitle(Long titleId, String titleName) { - Title title = Title.builder() + Title title = title(titleId, titleName, TitleAcquisitionType.DEFAULT, 0L, null); + + return MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build(); + } + + private Title title( + Long titleId, + String titleName, + TitleAcquisitionType acquisitionType, + long price, + String requirementText + ) { + return Title.builder() .id(titleId) .code("TITLE_" + titleId) .name(titleName) .tier(TitleTier.BASIC) - .acquisitionType(TitleAcquisitionType.DEFAULT) + .acquisitionType(acquisitionType) + .price(price) + .requirementText(requirementText) .build(); + } - return MemberProfileTitle.builder() - .memberProfile(profile) - .title(title) - .build(); + private boolean containsOnlyTitle(Iterable<MemberProfileTitle> savedTitles, Long titleId) { + int count = 0; + boolean matched = false; + for (MemberProfileTitle savedTitle : savedTitles) { + count++; + matched = savedTitle.getTitle().getId().equals(titleId); + } + return count == 1 && matched; } } From d66be8ed64e77026fd41e166c0faa3400177f158 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Wed, 13 May 2026 17:34:06 +0900 Subject: [PATCH 28/43] =?UTF-8?q?chore:=20=EC=B9=AD=ED=98=B8=20API=20CORS?= =?UTF-8?q?=20=ED=97=88=EC=9A=A9=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/valanse/common/config/SecurityConfig.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index a5a2acf..3551cbc 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -116,13 +116,20 @@ public CorsConfigurationSource corsConfigurationSource() { "https://valanserver.store:8081", "https://valanserver.store:8082" )); + configuration.setAllowedOriginPatterns(Arrays.asList( + "http://localhost:*", + "http://127.0.0.1:*" + )); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/member/titles", configuration); + source.registerCorsConfiguration("/member/titles/**", configuration); source.registerCorsConfiguration("/**", configuration); return source; } -} \ No newline at end of file +} From 10f629e454a5bb80e16182b54bd4bdfaf4ea016e Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Wed, 13 May 2026 23:12:53 +0900 Subject: [PATCH 29/43] =?UTF-8?q?feat=20:=20=EC=B9=AD=ED=98=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=20=EC=83=89?= =?UTF-8?q?=EC=83=81=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/valanse/valanse/dto/Title/TitleListResponse.java | 3 +-- .../valanse/valanse/service/TitleService/TitleServiceImpl.java | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java index 904f179..46a1c79 100644 --- a/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleListResponse.java @@ -21,8 +21,7 @@ public record TitleSummaryResponse( boolean locked, Long price, String requirementText, - String lockReason, - String colorHex + String lockReason ) { } } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 360fc9c..8a34d31 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -114,8 +114,7 @@ private TitleListResponse.TitleSummaryResponse toTitleSummary( locked, title.getPrice(), title.getRequirementText(), - locked ? getLockReason(title) : null, - title.getColorHex() + locked ? getLockReason(title) : null ); } From fb1e24110cec6bb0c8d509dd996fee417b5b2cbf Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Wed, 13 May 2026 23:25:37 +0900 Subject: [PATCH 30/43] =?UTF-8?q?feat=20:=20=EC=B9=AD=ED=98=B8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EC=97=90=EC=84=9C=20=EC=83=89=EC=83=81=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/valanse/valanse/domain/Title.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/valanse/valanse/domain/Title.java b/src/main/java/com/valanse/valanse/domain/Title.java index c6d5d18..5a9efdf 100644 --- a/src/main/java/com/valanse/valanse/domain/Title.java +++ b/src/main/java/com/valanse/valanse/domain/Title.java @@ -47,8 +47,6 @@ public class Title extends BaseEntity { private String requirementText; - private String colorHex; - @Builder.Default private boolean active = true; From 099612766b2f521bfad613b823ceeb8e17394c87 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Thu, 14 May 2026 14:45:05 +0900 Subject: [PATCH 31/43] =?UTF-8?q?feat:=20=EC=B9=AD=ED=98=B8=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/Title/TitlePurchaseResponse.java | 9 ++++ .../service/TitleService/TitleService.java | 3 ++ .../TitleService/TitleServiceImpl.java | 36 +++++++++++++++ .../TitleService/TitleServiceImplTest.java | 44 +++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitlePurchaseResponse.java diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitlePurchaseResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitlePurchaseResponse.java new file mode 100644 index 0000000..ddf3d0e --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitlePurchaseResponse.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.dto.Title; + +public record TitlePurchaseResponse( + Long titleId, + String title, + boolean owned, + long remainingPoint +) { +} diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java index 099c06b..20b37b1 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -2,9 +2,12 @@ import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; +import com.valanse.valanse.dto.Title.TitlePurchaseResponse; public interface TitleService { TitleListResponse getTitleList(Long userId); TitleEquipResponse equipTitle(Long userId, Long titleId); + + TitlePurchaseResponse purchaseTitle(Long userId, Long titleId); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 8a34d31..23363a6 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -5,6 +5,7 @@ import com.valanse.valanse.domain.Title; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; +import com.valanse.valanse.dto.Title.TitlePurchaseResponse; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.TitleRepository; @@ -97,6 +98,41 @@ public TitleEquipResponse equipTitle(Long userId, Long titleId) { ); } + @Override + public TitlePurchaseResponse purchaseTitle(Long userId, Long titleId) { + MemberProfile profile = memberProfileRepository.findByMemberId(userId) + .orElseThrow(() -> new IllegalArgumentException("프로필이 존재하지 않습니다.")); + + Title title = titleRepository.findById(titleId) + .orElseThrow(() -> new IllegalArgumentException("칭호가 존재하지 않습니다.")); + + if (!title.isPointPurchaseTitle() || !title.isActive()) { + throw new IllegalArgumentException("구매할 수 없는 칭호입니다."); + } + + memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(userId, titleId) + .ifPresent(ownedTitle -> { + throw new IllegalArgumentException("이미 보유한 칭호입니다."); + }); + + if (!profile.hasEnoughPoint(title.getPrice())) { + throw new IllegalArgumentException("포인트가 부족합니다. (필요포인트 " + title.getPrice() + "P 필요)"); + } + + profile.subtractPoint(title.getPrice()); + memberProfileTitleRepository.save(MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .build()); + + return new TitlePurchaseResponse( + title.getId(), + title.getName(), + true, + profile.getPoint() + ); + } + private TitleListResponse.TitleSummaryResponse toTitleSummary( Title title, MemberProfileTitle profileTitle, diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java index 41375d3..91622ac 100644 --- a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -168,6 +168,50 @@ void setup() { verify(memberProfileTitleRepository, never()).findByMemberProfileMemberIdAndTitleId(1L, 1L); } + @Test + @DisplayName("purchaseTitle()은 포인트 구매형 칭호를 구매하고 포인트를 차감한다") + void purchaseTitle_구매성공_포인트차감() { + profile.addPoint(500L); + Title pointTitle = title(3L, "선택의 신", TitleAcquisitionType.POINT_PURCHASE, 300L, null); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findById(3L)).thenReturn(Optional.of(pointTitle)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 3L)) + .thenReturn(Optional.empty()); + + var response = titleService.purchaseTitle(1L, 3L); + + assertThat(profile.getPoint()).isEqualTo(200L); + assertThat(response.titleId()).isEqualTo(3L); + assertThat(response.title()).isEqualTo("선택의 신"); + assertThat(response.owned()).isTrue(); + assertThat(response.remainingPoint()).isEqualTo(200L); + verify(memberProfileTitleRepository).save(org.mockito.ArgumentMatchers.argThat(savedTitle -> + savedTitle.getMemberProfile() == profile && savedTitle.getTitle().getId().equals(3L) + )); + } + + @Test + @DisplayName("purchaseTitle()은 포인트가 부족하면 필요 포인트와 함께 예외를 던진다") + void purchaseTitle_포인트부족_예외() { + profile.addPoint(100L); + Title pointTitle = title(3L, "선택의 신", TitleAcquisitionType.POINT_PURCHASE, 300L, null); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(titleRepository.findById(3L)).thenReturn(Optional.of(pointTitle)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 3L)) + .thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.purchaseTitle(1L, 3L) + ); + + assertThat(exception.getMessage()).isEqualTo("포인트가 부족합니다. (필요포인트 300P 필요)"); + assertThat(profile.getPoint()).isEqualTo(100L); + verify(memberProfileTitleRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + private MemberProfileTitle equippedProfileTitle(Long titleId, String titleName) { MemberProfileTitle profileTitle = profileTitle(titleId, titleName); profileTitle.equip(); From f486dd3c2bc49f4d884d31d39dda500fa8a5f110 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Thu, 14 May 2026 14:49:46 +0900 Subject: [PATCH 32/43] =?UTF-8?q?feat:=20=EC=B9=AD=ED=98=B8=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/valanse/controller/MemberController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index 10e34c4..d3d22ff 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -6,6 +6,7 @@ import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; +import com.valanse.valanse.dto.Title.TitlePurchaseResponse; import com.valanse.valanse.service.MemberProfileService.MemberProfileService; import com.valanse.valanse.service.PointService.PointService; import com.valanse.valanse.service.TitleService.TitleService; @@ -133,4 +134,15 @@ public ResponseEntity<TitleEquipResponse> equipTitle(@PathVariable Long titleId) TitleEquipResponse response = titleService.equipTitle(userId, titleId); return ResponseEntity.ok(response); } + + @Operation( + summary = "칭호 구매", + description = "현재 로그인한 회원이 포인트로 구매 가능한 칭호를 구매합니다." + ) + @PostMapping("/titles/{titleId}/purchase") + public ResponseEntity<TitlePurchaseResponse> purchaseTitle(@PathVariable Long titleId) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitlePurchaseResponse response = titleService.purchaseTitle(userId, titleId); + return ResponseEntity.ok(response); + } } From 1c62b56d80327c5d7444ec3705e558e1eb9e4424 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Thu, 14 May 2026 14:57:38 +0900 Subject: [PATCH 33/43] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=B9=AD=ED=98=B8=20=EC=83=9D=EC=84=B1=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/controller/MemberController.java | 13 ++ .../valanse/dto/Title/TitleCreateRequest.java | 17 +++ .../dto/Title/TitleCreateResponse.java | 18 +++ .../valanse/repository/TitleRepository.java | 2 + .../service/TitleService/TitleService.java | 4 + .../TitleService/TitleServiceImpl.java | 89 ++++++++++++ .../TitleService/TitleServiceImplTest.java | 132 ++++++++++++++++++ 7 files changed, 275 insertions(+) create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleCreateRequest.java create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleCreateResponse.java diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index d3d22ff..fbbcf67 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -4,6 +4,8 @@ import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleCreateResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.dto.Title.TitlePurchaseResponse; @@ -124,6 +126,17 @@ public ResponseEntity<TitleListResponse> getTitles() { return ResponseEntity.ok(response); } + @Operation( + summary = "관리자 칭호 생성", + description = "관리자 권한으로 새로운 칭호 마스터 데이터를 생성합니다." + ) + @PostMapping("/titles") + public ResponseEntity<TitleCreateResponse> createTitle(@RequestBody TitleCreateRequest request) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleCreateResponse response = titleService.createTitle(userId, request); + return ResponseEntity.ok(response); + } + @Operation( summary = "칭호 장착", description = "현재 로그인한 회원이 보유한 칭호를 대표 칭호로 선택합니다." diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleCreateRequest.java b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateRequest.java new file mode 100644 index 0000000..6dba156 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateRequest.java @@ -0,0 +1,17 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleCreateRequest( + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + Boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleCreateResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateResponse.java new file mode 100644 index 0000000..68abc5c --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleCreateResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleCreateResponse( + Long titleId, + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/repository/TitleRepository.java b/src/main/java/com/valanse/valanse/repository/TitleRepository.java index 10c559a..55d7feb 100644 --- a/src/main/java/com/valanse/valanse/repository/TitleRepository.java +++ b/src/main/java/com/valanse/valanse/repository/TitleRepository.java @@ -9,4 +9,6 @@ @Repository public interface TitleRepository extends JpaRepository<Title, Long> { List<Title> findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); + + boolean existsByCode(String code); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java index 20b37b1..4e404a1 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -1,5 +1,7 @@ package com.valanse.valanse.service.TitleService; +import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleCreateResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.dto.Title.TitlePurchaseResponse; @@ -10,4 +12,6 @@ public interface TitleService { TitleEquipResponse equipTitle(Long userId, Long titleId); TitlePurchaseResponse purchaseTitle(Long userId, Long titleId); + + TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 23363a6..66562ec 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -1,15 +1,22 @@ package com.valanse.valanse.service.TitleService; +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.MemberProfileTitle; import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleCreateResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.dto.Title.TitlePurchaseResponse; +import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.TitleRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +31,7 @@ @Transactional public class TitleServiceImpl implements TitleService { + private final MemberRepository memberRepository; private final MemberProfileRepository memberProfileRepository; private final MemberProfileTitleRepository memberProfileTitleRepository; private final TitleRepository titleRepository; @@ -133,6 +141,37 @@ public TitlePurchaseResponse purchaseTitle(Long userId, Long titleId) { ); } + @Override + public TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request) { + Member admin = memberRepository.findByIdAndDeletedAtIsNull(adminUserId) + .orElseThrow(() -> new ApiException("회원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); + if (admin.getRole() != Role.ADMIN) { + throw new ApiException("관리자만 접근 가능합니다.", HttpStatus.FORBIDDEN); + } + + validateCreateRequest(request); + + String code = request.code().trim(); + if (titleRepository.existsByCode(code)) { + throw new ApiException("이미 존재하는 칭호 코드입니다.", HttpStatus.BAD_REQUEST); + } + + Title title = Title.builder() + .code(code) + .name(request.title().trim()) + .description(trimToNull(request.description())) + .price(request.price() == null ? 0L : request.price()) + .tier(request.tier()) + .acquisitionType(request.acquisitionType()) + .requirementText(trimToNull(request.requirementText())) + .active(request.active() == null || request.active()) + .displayOrder(request.displayOrder() == null ? 0 : request.displayOrder()) + .build(); + + Title savedTitle = titleRepository.save(title); + return toTitleCreateResponse(savedTitle); + } + private TitleListResponse.TitleSummaryResponse toTitleSummary( Title title, MemberProfileTitle profileTitle, @@ -163,4 +202,54 @@ private String getLockReason(Title title) { } return "획득 조건을 달성해야 합니다."; } + + private void validateCreateRequest(TitleCreateRequest request) { + if (request == null) { + throw new ApiException("칭호 생성 요청이 비어있습니다.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.code())) { + throw new ApiException("칭호 코드를 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.title())) { + throw new ApiException("칭호 이름을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.tier() == null) { + throw new ApiException("칭호 등급을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.acquisitionType() == null) { + throw new ApiException("칭호 획득 방식을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.price() != null && request.price() < 0) { + throw new ApiException("칭호 가격은 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + if (request.displayOrder() != null && request.displayOrder() < 0) { + throw new ApiException("칭호 표시 순서는 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private TitleCreateResponse toTitleCreateResponse(Title title) { + return new TitleCreateResponse( + title.getId(), + title.getCode(), + title.getName(), + title.getDescription(), + title.getPrice(), + title.getTier(), + title.getAcquisitionType(), + title.getRequirementText(), + title.isActive(), + title.getDisplayOrder() + ); + } } diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java index 91622ac..74b0dfb 100644 --- a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -1,13 +1,17 @@ package com.valanse.valanse.service.TitleService; +import com.valanse.valanse.common.api.ApiException; import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.MemberProfileTitle; import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.TitleAcquisitionType; import com.valanse.valanse.domain.enums.TitleTier; +import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; +import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.TitleRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -22,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,6 +37,9 @@ class TitleServiceImplTest { @InjectMocks private TitleServiceImpl titleService; + @Mock + private MemberRepository memberRepository; + @Mock private MemberProfileRepository memberProfileRepository; @@ -212,6 +220,116 @@ void setup() { verify(memberProfileTitleRepository, never()).save(org.mockito.ArgumentMatchers.any()); } + @Test + @DisplayName("createTitle()은 관리자가 새로운 칭호를 생성한다") + void createTitle_관리자_칭호생성() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + TitleCreateRequest request = new TitleCreateRequest( + " CHOICE_MASTER ", + " 선택의 달인 ", + "투표 참여 고수", + 300L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "300P 필요", + true, + 10 + ); + Title savedTitle = Title.builder() + .id(10L) + .code("CHOICE_MASTER") + .name("선택의 달인") + .description("투표 참여 고수") + .price(300L) + .tier(TitleTier.RARE) + .acquisitionType(TitleAcquisitionType.POINT_PURCHASE) + .requirementText("300P 필요") + .active(true) + .displayOrder(10) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.existsByCode("CHOICE_MASTER")).thenReturn(false); + when(titleRepository.save(any(Title.class))).thenReturn(savedTitle); + + var response = titleService.createTitle(1L, request); + + assertThat(response.titleId()).isEqualTo(10L); + assertThat(response.code()).isEqualTo("CHOICE_MASTER"); + assertThat(response.title()).isEqualTo("선택의 달인"); + assertThat(response.price()).isEqualTo(300L); + assertThat(response.acquisitionType()).isEqualTo(TitleAcquisitionType.POINT_PURCHASE); + verify(titleRepository).save(org.mockito.ArgumentMatchers.argThat(title -> + title.getCode().equals("CHOICE_MASTER") + && title.getName().equals("선택의 달인") + && title.getPrice() == 300L + && title.getTier() == TitleTier.RARE + && title.getAcquisitionType() == TitleAcquisitionType.POINT_PURCHASE + && title.getDisplayOrder() == 10 + )); + } + + @Test + @DisplayName("createTitle()은 관리자가 아니면 예외를 던진다") + void createTitle_관리자아님_예외() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.createTitle(1L, validCreateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("관리자만 접근 가능합니다."); + verify(titleRepository, never()).save(any()); + } + + @Test + @DisplayName("createTitle()은 중복된 칭호 코드를 생성할 수 없다") + void createTitle_중복코드_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.existsByCode("CHOICE_MASTER")).thenReturn(true); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.createTitle(1L, validCreateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("이미 존재하는 칭호 코드입니다."); + verify(titleRepository, never()).save(any()); + } + + @Test + @DisplayName("createTitle()은 필수값이 없으면 예외를 던진다") + void createTitle_필수값없음_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + TitleCreateRequest request = new TitleCreateRequest( + "CHOICE_MASTER", + " ", + null, + null, + TitleTier.BASIC, + TitleAcquisitionType.ACHIEVEMENT, + null, + null, + null + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.createTitle(1L, request) + ); + + assertThat(exception.getMessage()).isEqualTo("칭호 이름을 입력해주세요."); + verify(titleRepository, never()).existsByCode("CHOICE_MASTER"); + verify(titleRepository, never()).save(any()); + } + private MemberProfileTitle equippedProfileTitle(Long titleId, String titleName) { MemberProfileTitle profileTitle = profileTitle(titleId, titleName); profileTitle.equip(); @@ -245,6 +363,20 @@ private Title title( .build(); } + private TitleCreateRequest validCreateRequest() { + return new TitleCreateRequest( + "CHOICE_MASTER", + "선택의 달인", + "투표 참여 고수", + 300L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "300P 필요", + true, + 10 + ); + } + private boolean containsOnlyTitle(Iterable<MemberProfileTitle> savedTitles, Long titleId) { int count = 0; boolean matched = false; From bb0a501b362a193faec1fa9ebfb8a7185760ee54 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Thu, 14 May 2026 15:47:09 +0900 Subject: [PATCH 34/43] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=B9=AD=ED=98=B8=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/controller/MemberController.java | 28 +++ .../com/valanse/valanse/domain/Title.java | 22 ++ .../dto/Title/TitleDeleteResponse.java | 11 + .../valanse/dto/Title/TitleUpdateRequest.java | 17 ++ .../dto/Title/TitleUpdateResponse.java | 18 ++ .../MemberProfileTitleRepository.java | 4 + .../valanse/repository/TitleRepository.java | 8 + .../service/TitleService/TitleService.java | 7 + .../TitleService/TitleServiceImpl.java | 132 +++++++++++- .../TitleService/TitleServiceImplTest.java | 188 ++++++++++++++++++ 10 files changed, 430 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleDeleteResponse.java create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleUpdateRequest.java create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleUpdateResponse.java diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index fbbcf67..94aaa56 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -6,9 +6,12 @@ import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleDeleteResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.dto.Title.TitlePurchaseResponse; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateResponse; import com.valanse.valanse.service.MemberProfileService.MemberProfileService; import com.valanse.valanse.service.PointService.PointService; import com.valanse.valanse.service.TitleService.TitleService; @@ -137,6 +140,31 @@ public ResponseEntity<TitleCreateResponse> createTitle(@RequestBody TitleCreateR return ResponseEntity.ok(response); } + @Operation( + summary = "관리자 칭호 수정", + description = "관리자 권한으로 칭호 마스터 데이터를 수정합니다." + ) + @PatchMapping("/titles/{titleId}") + public ResponseEntity<TitleUpdateResponse> updateTitle( + @PathVariable Long titleId, + @RequestBody TitleUpdateRequest request + ) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleUpdateResponse response = titleService.updateTitle(userId, titleId, request); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "관리자 칭호 삭제", + description = "관리자 권한으로 칭호를 비활성화합니다. 삭제 대상 칭호를 장착 중인 회원은 활성 기본 칭호로 변경됩니다." + ) + @DeleteMapping("/titles/{titleId}") + public ResponseEntity<TitleDeleteResponse> deleteTitle(@PathVariable Long titleId) { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + TitleDeleteResponse response = titleService.deleteTitle(userId, titleId); + return ResponseEntity.ok(response); + } + @Operation( summary = "칭호 장착", description = "현재 로그인한 회원이 보유한 칭호를 대표 칭호로 선택합니다." diff --git a/src/main/java/com/valanse/valanse/domain/Title.java b/src/main/java/com/valanse/valanse/domain/Title.java index 5a9efdf..80c7b20 100644 --- a/src/main/java/com/valanse/valanse/domain/Title.java +++ b/src/main/java/com/valanse/valanse/domain/Title.java @@ -68,4 +68,26 @@ public boolean isPurchasable(long point) { public void deactivate() { this.active = false; } + + public void update( + String code, + String name, + String description, + long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + boolean active, + int displayOrder + ) { + this.code = code; + this.name = name; + this.description = description; + this.price = price; + this.tier = tier; + this.acquisitionType = acquisitionType; + this.requirementText = requirementText; + this.active = active; + this.displayOrder = displayOrder; + } } diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleDeleteResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleDeleteResponse.java new file mode 100644 index 0000000..4e72cad --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleDeleteResponse.java @@ -0,0 +1,11 @@ +package com.valanse.valanse.dto.Title; + +public record TitleDeleteResponse( + Long deletedTitleId, + String deletedTitle, + Long fallbackTitleId, + String fallbackTitle, + int reassignedCount, + boolean active +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateRequest.java b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateRequest.java new file mode 100644 index 0000000..0c7d235 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateRequest.java @@ -0,0 +1,17 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleUpdateRequest( + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + Boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateResponse.java new file mode 100644 index 0000000..055cc6f --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleUpdateResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleUpdateResponse( + Long titleId, + String code, + String title, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + boolean active, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java index ca10fd0..5149de9 100644 --- a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java @@ -9,7 +9,11 @@ public interface MemberProfileTitleRepository extends JpaRepository<MemberProfileTitle, Long> { Optional<MemberProfileTitle> findByMemberProfileMemberIdAndTitleId(Long memberId, Long titleId); + Optional<MemberProfileTitle> findByMemberProfileIdAndTitleId(Long memberProfileId, Long titleId); + List<MemberProfileTitle> findAllByMemberProfileMemberIdAndEquippedTrue(Long memberId); List<MemberProfileTitle> findAllByMemberProfileMemberId(Long memberId); + + List<MemberProfileTitle> findAllByTitleIdAndEquippedTrue(Long titleId); } diff --git a/src/main/java/com/valanse/valanse/repository/TitleRepository.java b/src/main/java/com/valanse/valanse/repository/TitleRepository.java index 55d7feb..c4c2b7b 100644 --- a/src/main/java/com/valanse/valanse/repository/TitleRepository.java +++ b/src/main/java/com/valanse/valanse/repository/TitleRepository.java @@ -1,14 +1,22 @@ package com.valanse.valanse.repository; import com.valanse.valanse.domain.Title; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface TitleRepository extends JpaRepository<Title, Long> { List<Title> findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); + Optional<Title> findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc( + TitleAcquisitionType acquisitionType + ); + boolean existsByCode(String code); + + boolean existsByCodeAndIdNot(String code, Long id); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java index 4e404a1..b23a025 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -2,9 +2,12 @@ import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleDeleteResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.dto.Title.TitlePurchaseResponse; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateResponse; public interface TitleService { TitleListResponse getTitleList(Long userId); @@ -14,4 +17,8 @@ public interface TitleService { TitlePurchaseResponse purchaseTitle(Long userId, Long titleId); TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request); + + TitleUpdateResponse updateTitle(Long adminUserId, Long titleId, TitleUpdateRequest request); + + TitleDeleteResponse deleteTitle(Long adminUserId, Long titleId); } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 66562ec..e6f0281 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -6,11 +6,15 @@ import com.valanse.valanse.domain.MemberProfileTitle; import com.valanse.valanse.domain.Title; import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleDeleteResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; import com.valanse.valanse.dto.Title.TitlePurchaseResponse; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateResponse; import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; @@ -95,6 +99,10 @@ public TitleEquipResponse equipTitle(Long userId, Long titleId) { .findByMemberProfileMemberIdAndTitleId(userId, titleId) .orElseThrow(() -> new IllegalArgumentException("보유하지 않은 칭호입니다.")); + if (!targetTitle.getTitle().isActive()) { + throw new IllegalArgumentException("장착할 수 없는 칭호입니다."); + } + memberProfileTitleRepository.findAllByMemberProfileMemberIdAndEquippedTrue(userId) .forEach(MemberProfileTitle::unequip); targetTitle.equip(); @@ -143,11 +151,7 @@ public TitlePurchaseResponse purchaseTitle(Long userId, Long titleId) { @Override public TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request) { - Member admin = memberRepository.findByIdAndDeletedAtIsNull(adminUserId) - .orElseThrow(() -> new ApiException("회원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); - if (admin.getRole() != Role.ADMIN) { - throw new ApiException("관리자만 접근 가능합니다.", HttpStatus.FORBIDDEN); - } + validateAdmin(adminUserId); validateCreateRequest(request); @@ -172,6 +176,85 @@ public TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest requ return toTitleCreateResponse(savedTitle); } + @Override + public TitleUpdateResponse updateTitle(Long adminUserId, Long titleId, TitleUpdateRequest request) { + validateAdmin(adminUserId); + + validateUpdateRequest(request); + + Title title = titleRepository.findById(titleId) + .orElseThrow(() -> new ApiException("칭호가 존재하지 않습니다.", HttpStatus.NOT_FOUND)); + + String code = request.code().trim(); + if (titleRepository.existsByCodeAndIdNot(code, titleId)) { + throw new ApiException("이미 존재하는 칭호 코드입니다.", HttpStatus.BAD_REQUEST); + } + + title.update( + code, + request.title().trim(), + trimToNull(request.description()), + request.price() == null ? 0L : request.price(), + request.tier(), + request.acquisitionType(), + trimToNull(request.requirementText()), + request.active() == null || request.active(), + request.displayOrder() == null ? 0 : request.displayOrder() + ); + + return toTitleUpdateResponse(title); + } + + @Override + public TitleDeleteResponse deleteTitle(Long adminUserId, Long titleId) { + validateAdmin(adminUserId); + + Title title = titleRepository.findById(titleId) + .orElseThrow(() -> new ApiException("칭호가 존재하지 않습니다.", HttpStatus.NOT_FOUND)); + + if (!title.isActive()) { + throw new ApiException("이미 삭제된 칭호입니다.", HttpStatus.BAD_REQUEST); + } + if (title.isDefaultTitle()) { + throw new ApiException("기본 칭호는 삭제할 수 없습니다.", HttpStatus.BAD_REQUEST); + } + + Title fallbackTitle = titleRepository + .findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT) + .orElseThrow(() -> new ApiException("기본 칭호가 존재하지 않습니다.", HttpStatus.INTERNAL_SERVER_ERROR)); + + List<MemberProfileTitle> equippedTitles = memberProfileTitleRepository.findAllByTitleIdAndEquippedTrue(titleId); + for (MemberProfileTitle equippedTitle : equippedTitles) { + equippedTitle.unequip(); + MemberProfile profile = equippedTitle.getMemberProfile(); + MemberProfileTitle fallbackProfileTitle = memberProfileTitleRepository + .findByMemberProfileIdAndTitleId(profile.getId(), fallbackTitle.getId()) + .orElseGet(() -> memberProfileTitleRepository.save(MemberProfileTitle.builder() + .memberProfile(profile) + .title(fallbackTitle) + .build())); + fallbackProfileTitle.equip(); + } + title.deactivate(); + + return new TitleDeleteResponse( + title.getId(), + title.getName(), + fallbackTitle.getId(), + fallbackTitle.getName(), + equippedTitles.size(), + title.isActive() + ); + } + + private void validateAdmin(Long adminUserId) { + Member admin = memberRepository.findByIdAndDeletedAtIsNull(adminUserId) + .orElseThrow(() -> new ApiException("회원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND)); + if (admin.getRole() != Role.ADMIN) { + throw new ApiException("관리자만 접근 가능합니다.", HttpStatus.FORBIDDEN); + } + } + private TitleListResponse.TitleSummaryResponse toTitleSummary( Title title, MemberProfileTitle profileTitle, @@ -227,6 +310,30 @@ private void validateCreateRequest(TitleCreateRequest request) { } } + private void validateUpdateRequest(TitleUpdateRequest request) { + if (request == null) { + throw new ApiException("칭호 수정 요청이 비어있습니다.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.code())) { + throw new ApiException("칭호 코드를 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (isBlank(request.title())) { + throw new ApiException("칭호 이름을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.tier() == null) { + throw new ApiException("칭호 등급을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.acquisitionType() == null) { + throw new ApiException("칭호 획득 방식을 입력해주세요.", HttpStatus.BAD_REQUEST); + } + if (request.price() != null && request.price() < 0) { + throw new ApiException("칭호 가격은 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + if (request.displayOrder() != null && request.displayOrder() < 0) { + throw new ApiException("칭호 표시 순서는 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + } + private boolean isBlank(String value) { return value == null || value.isBlank(); } @@ -252,4 +359,19 @@ private TitleCreateResponse toTitleCreateResponse(Title title) { title.getDisplayOrder() ); } + + private TitleUpdateResponse toTitleUpdateResponse(Title title) { + return new TitleUpdateResponse( + title.getId(), + title.getCode(), + title.getName(), + title.getDescription(), + title.getPrice(), + title.getTier(), + title.getAcquisitionType(), + title.getRequirementText(), + title.isActive(), + title.getDisplayOrder() + ); + } } diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java index 74b0dfb..062bed7 100644 --- a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -9,6 +9,7 @@ import com.valanse.valanse.domain.enums.TitleAcquisitionType; import com.valanse.valanse.domain.enums.TitleTier; import com.valanse.valanse.dto.Title.TitleCreateRequest; +import com.valanse.valanse.dto.Title.TitleUpdateRequest; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.MemberRepository; @@ -61,6 +62,7 @@ void setup() { .name("test") .build(); profile = MemberProfile.builder() + .id(1L) .member(member) .nickname("테스트닉네임") .build(); @@ -162,6 +164,35 @@ void setup() { verify(memberProfileTitleRepository, never()).findAllByMemberProfileMemberIdAndEquippedTrue(1L); } + @Test + @DisplayName("equipTitle()은 삭제된 칭호를 장착할 수 없다") + void equipTitle_삭제된칭호_예외() { + Title inactiveTitle = Title.builder() + .id(99L) + .code("DELETED_TITLE") + .name("삭제된 칭호") + .tier(TitleTier.BASIC) + .acquisitionType(TitleAcquisitionType.ACHIEVEMENT) + .active(false) + .build(); + MemberProfileTitle inactiveProfileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(inactiveTitle) + .build(); + + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); + when(memberProfileTitleRepository.findByMemberProfileMemberIdAndTitleId(1L, 99L)) + .thenReturn(Optional.of(inactiveProfileTitle)); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> titleService.equipTitle(1L, 99L) + ); + + assertThat(exception.getMessage()).isEqualTo("장착할 수 없는 칭호입니다."); + verify(memberProfileTitleRepository, never()).findAllByMemberProfileMemberIdAndEquippedTrue(1L); + } + @Test @DisplayName("equipTitle()은 프로필이 없으면 예외를 던진다") void equipTitle_프로필없음_예외() { @@ -330,6 +361,149 @@ void setup() { verify(titleRepository, never()).save(any()); } + @Test + @DisplayName("updateTitle()은 관리자가 칭호를 수정한다") + void updateTitle_관리자_칭호수정() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title targetTitle = title(2L, "기존 칭호", TitleAcquisitionType.ACHIEVEMENT, 0L, null); + TitleUpdateRequest request = new TitleUpdateRequest( + " UPDATED_TITLE ", + " 수정된 칭호 ", + "수정된 설명", + 500L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "500P 필요", + false, + 20 + ); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(2L)).thenReturn(Optional.of(targetTitle)); + when(titleRepository.existsByCodeAndIdNot("UPDATED_TITLE", 2L)).thenReturn(false); + + var response = titleService.updateTitle(1L, 2L, request); + + assertThat(response.titleId()).isEqualTo(2L); + assertThat(response.code()).isEqualTo("UPDATED_TITLE"); + assertThat(response.title()).isEqualTo("수정된 칭호"); + assertThat(response.description()).isEqualTo("수정된 설명"); + assertThat(response.price()).isEqualTo(500L); + assertThat(response.tier()).isEqualTo(TitleTier.RARE); + assertThat(response.acquisitionType()).isEqualTo(TitleAcquisitionType.POINT_PURCHASE); + assertThat(response.requirementText()).isEqualTo("500P 필요"); + assertThat(response.active()).isFalse(); + assertThat(response.displayOrder()).isEqualTo(20); + assertThat(targetTitle.getCode()).isEqualTo("UPDATED_TITLE"); + assertThat(targetTitle.getName()).isEqualTo("수정된 칭호"); + } + + @Test + @DisplayName("updateTitle()은 중복된 칭호 코드로 수정할 수 없다") + void updateTitle_중복코드_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title targetTitle = title(2L, "기존 칭호", TitleAcquisitionType.ACHIEVEMENT, 0L, null); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(2L)).thenReturn(Optional.of(targetTitle)); + when(titleRepository.existsByCodeAndIdNot("CHOICE_MASTER", 2L)).thenReturn(true); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.updateTitle(1L, 2L, validUpdateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("이미 존재하는 칭호 코드입니다."); + assertThat(targetTitle.getCode()).isEqualTo("TITLE_2"); + } + + @Test + @DisplayName("updateTitle()은 관리자가 아니면 예외를 던진다") + void updateTitle_관리자아님_예외() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.updateTitle(1L, 2L, validUpdateRequest()) + ); + + assertThat(exception.getMessage()).isEqualTo("관리자만 접근 가능합니다."); + verify(titleRepository, never()).findById(2L); + } + + @Test + @DisplayName("deleteTitle()은 관리자가 칭호를 삭제하고 장착 회원을 기본 칭호로 변경한다") + void deleteTitle_관리자_장착회원_기본칭호변경() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title targetTitle = title(2L, "시즌 칭호", TitleAcquisitionType.SEASON, 0L, null); + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + MemberProfileTitle equippedTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(targetTitle) + .build(); + equippedTitle.equip(); + MemberProfileTitle fallbackProfileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(defaultTitle) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(2L)).thenReturn(Optional.of(targetTitle)); + when(titleRepository.findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT)) + .thenReturn(Optional.of(defaultTitle)); + when(memberProfileTitleRepository.findAllByTitleIdAndEquippedTrue(2L)) + .thenReturn(List.of(equippedTitle)); + when(memberProfileTitleRepository.findByMemberProfileIdAndTitleId(1L, 1L)) + .thenReturn(Optional.of(fallbackProfileTitle)); + + var response = titleService.deleteTitle(1L, 2L); + + assertThat(targetTitle.isActive()).isFalse(); + assertThat(equippedTitle.isEquipped()).isFalse(); + assertThat(fallbackProfileTitle.isEquipped()).isTrue(); + assertThat(response.deletedTitleId()).isEqualTo(2L); + assertThat(response.fallbackTitleId()).isEqualTo(1L); + assertThat(response.reassignedCount()).isEqualTo(1); + assertThat(response.active()).isFalse(); + } + + @Test + @DisplayName("deleteTitle()은 기본 칭호를 삭제할 수 없다") + void deleteTitle_기본칭호_예외() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title defaultTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findById(1L)).thenReturn(Optional.of(defaultTitle)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.deleteTitle(1L, 1L) + ); + + assertThat(exception.getMessage()).isEqualTo("기본 칭호는 삭제할 수 없습니다."); + assertThat(defaultTitle.isActive()).isTrue(); + verify(memberProfileTitleRepository, never()).findAllByTitleIdAndEquippedTrue(1L); + } + + @Test + @DisplayName("deleteTitle()은 관리자가 아니면 예외를 던진다") + void deleteTitle_관리자아님_예외() { + Member user = Member.builder().id(1L).role(Role.USER).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + ApiException exception = assertThrows( + ApiException.class, + () -> titleService.deleteTitle(1L, 2L) + ); + + assertThat(exception.getMessage()).isEqualTo("관리자만 접근 가능합니다."); + verify(titleRepository, never()).findById(2L); + } + private MemberProfileTitle equippedProfileTitle(Long titleId, String titleName) { MemberProfileTitle profileTitle = profileTitle(titleId, titleName); profileTitle.equip(); @@ -377,6 +551,20 @@ private TitleCreateRequest validCreateRequest() { ); } + private TitleUpdateRequest validUpdateRequest() { + return new TitleUpdateRequest( + "CHOICE_MASTER", + "선택의 달인", + "투표 참여 고수", + 300L, + TitleTier.RARE, + TitleAcquisitionType.POINT_PURCHASE, + "300P 필요", + true, + 10 + ); + } + private boolean containsOnlyTitle(Iterable<MemberProfileTitle> savedTitles, Long titleId) { int count = 0; boolean matched = false; From eaa03042d9bac8385bf43458aa65f6d47d94d3c6 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Fri, 15 May 2026 15:07:36 +0900 Subject: [PATCH 35/43] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=B9=AD=ED=98=B8=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/controller/MemberController.java | 13 ++++++++++ .../valanse/dto/Title/TitleAdminResponse.java | 17 +++++++++++++ .../valanse/repository/TitleRepository.java | 2 ++ .../service/TitleService/TitleService.java | 5 ++++ .../TitleService/TitleServiceImpl.java | 25 +++++++++++++++++++ .../TitleService/TitleServiceImplTest.java | 20 +++++++++++++++ 6 files changed, 82 insertions(+) create mode 100644 src/main/java/com/valanse/valanse/dto/Title/TitleAdminResponse.java diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index 94aaa56..a22a73d 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -6,6 +6,7 @@ import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleAdminResponse; import com.valanse.valanse.dto.Title.TitleDeleteResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.*; import java.util.HashMap; +import java.util.List; import java.util.Map; @Tag(name = "회원 정보 API", description = "프로필 조회 등 회원 정보 관련 기능") @@ -129,6 +131,17 @@ public ResponseEntity<TitleListResponse> getTitles() { return ResponseEntity.ok(response); } + @Operation( + summary = "관리자 칭호 목록 조회", + description = "관리자 권한으로 잠김/보유 여부와 상관없이 칭호 마스터 데이터 목록을 조회합니다." + ) + @GetMapping("/titles/admin") + public ResponseEntity<List<TitleAdminResponse>> getTitlesForAdmin() { + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + List<TitleAdminResponse> response = titleService.getTitleListForAdmin(userId); + return ResponseEntity.ok(response); + } + @Operation( summary = "관리자 칭호 생성", description = "관리자 권한으로 새로운 칭호 마스터 데이터를 생성합니다." diff --git a/src/main/java/com/valanse/valanse/dto/Title/TitleAdminResponse.java b/src/main/java/com/valanse/valanse/dto/Title/TitleAdminResponse.java new file mode 100644 index 0000000..940a14d --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Title/TitleAdminResponse.java @@ -0,0 +1,17 @@ +package com.valanse.valanse.dto.Title; + +import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.TitleTier; + +public record TitleAdminResponse( + Long titleId, + String code, + String titleName, + String description, + Long price, + TitleTier tier, + TitleAcquisitionType acquisitionType, + String requirementText, + Integer displayOrder +) { +} diff --git a/src/main/java/com/valanse/valanse/repository/TitleRepository.java b/src/main/java/com/valanse/valanse/repository/TitleRepository.java index c4c2b7b..1987b48 100644 --- a/src/main/java/com/valanse/valanse/repository/TitleRepository.java +++ b/src/main/java/com/valanse/valanse/repository/TitleRepository.java @@ -12,6 +12,8 @@ public interface TitleRepository extends JpaRepository<Title, Long> { List<Title> findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); + List<Title> findAllByOrderByDisplayOrderAscIdAsc(); + Optional<Title> findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc( TitleAcquisitionType acquisitionType ); diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java index b23a025..073a687 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleService.java @@ -2,6 +2,7 @@ import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleAdminResponse; import com.valanse.valanse.dto.Title.TitleDeleteResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; @@ -9,6 +10,8 @@ import com.valanse.valanse.dto.Title.TitleUpdateRequest; import com.valanse.valanse.dto.Title.TitleUpdateResponse; +import java.util.List; + public interface TitleService { TitleListResponse getTitleList(Long userId); @@ -16,6 +19,8 @@ public interface TitleService { TitlePurchaseResponse purchaseTitle(Long userId, Long titleId); + List<TitleAdminResponse> getTitleListForAdmin(Long adminUserId); + TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request); TitleUpdateResponse updateTitle(Long adminUserId, Long titleId, TitleUpdateRequest request); diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index e6f0281..1ec0a17 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -9,6 +9,7 @@ import com.valanse.valanse.domain.enums.TitleAcquisitionType; import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.dto.Title.TitleCreateResponse; +import com.valanse.valanse.dto.Title.TitleAdminResponse; import com.valanse.valanse.dto.Title.TitleDeleteResponse; import com.valanse.valanse.dto.Title.TitleEquipResponse; import com.valanse.valanse.dto.Title.TitleListResponse; @@ -149,6 +150,16 @@ public TitlePurchaseResponse purchaseTitle(Long userId, Long titleId) { ); } + @Override + @Transactional(readOnly = true) + public List<TitleAdminResponse> getTitleListForAdmin(Long adminUserId) { + validateAdmin(adminUserId); + + return titleRepository.findAllByOrderByDisplayOrderAscIdAsc().stream() + .map(this::toTitleAdminResponse) + .toList(); + } + @Override public TitleCreateResponse createTitle(Long adminUserId, TitleCreateRequest request) { validateAdmin(adminUserId); @@ -286,6 +297,20 @@ private String getLockReason(Title title) { return "획득 조건을 달성해야 합니다."; } + private TitleAdminResponse toTitleAdminResponse(Title title) { + return new TitleAdminResponse( + title.getId(), + title.getCode(), + title.getName(), + title.getDescription(), + title.getPrice(), + title.getTier(), + title.getAcquisitionType(), + title.getRequirementText(), + title.getDisplayOrder() + ); + } + private void validateCreateRequest(TitleCreateRequest request) { if (request == null) { throw new ApiException("칭호 생성 요청이 비어있습니다.", HttpStatus.BAD_REQUEST); diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java index 062bed7..c7b9868 100644 --- a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -300,6 +300,26 @@ void setup() { )); } + @Test + @DisplayName("getTitleListForAdmin()은 관리자에게 칭호 마스터 데이터 목록을 반환한다") + void getTitleListForAdmin_관리자_칭호목록조회() { + Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); + Title firstTitle = title(1L, "밸런스 새싹", TitleAcquisitionType.DEFAULT, 0L, null); + Title secondTitle = title(2L, "선택의 달인", TitleAcquisitionType.POINT_PURCHASE, 300L, "300P 필요"); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(admin)); + when(titleRepository.findAllByOrderByDisplayOrderAscIdAsc()).thenReturn(List.of(firstTitle, secondTitle)); + + var response = titleService.getTitleListForAdmin(1L); + + assertThat(response).hasSize(2); + assertThat(response.get(0).titleId()).isEqualTo(1L); + assertThat(response.get(0).titleName()).isEqualTo("밸런스 새싹"); + assertThat(response.get(1).titleId()).isEqualTo(2L); + assertThat(response.get(1).price()).isEqualTo(300L); + assertThat(response.get(1).requirementText()).isEqualTo("300P 필요"); + } + @Test @DisplayName("createTitle()은 관리자가 아니면 예외를 던진다") void createTitle_관리자아님_예외() { From 0ec36a0c20953fe93e849e2f2f092ad0bdafa19c Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Mon, 18 May 2026 13:07:55 +0900 Subject: [PATCH 36/43] =?UTF-8?q?fix:=20=ED=88=AC=ED=91=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=BB=A4=EC=84=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?N+1=20=EC=BF=BC=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/VoteRepositoryImpl.java | 52 ++++++++++++++++--- .../service/VoteService/VoteServiceImpl.java | 4 +- .../controller/VoteControllerTest.java | 31 +++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java index ea01274..c8bf485 100644 --- a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java @@ -5,6 +5,8 @@ import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.valanse.valanse.domain.QCommentGroup; +import com.valanse.valanse.domain.QMember; +import com.valanse.valanse.domain.QMemberProfile; import com.valanse.valanse.domain.QVote; import com.valanse.valanse.domain.Vote; import com.valanse.valanse.domain.enums.VoteCategory; @@ -27,6 +29,8 @@ public class VoteRepositoryImpl implements VoteRepositoryCustom { private final JPAQueryFactory queryFactory; private final QVote vote = QVote.vote; + private final QMember member = QMember.member; + private final QMemberProfile memberProfile = QMemberProfile.memberProfile; private final QCommentGroup commentGroup = QCommentGroup.commentGroup; @Override @@ -38,11 +42,18 @@ public List<Vote> findVotesByCursor(String category, String sort, String cursor, } // 1. orderBy를 먼저 설정하여 어떤 정렬 기준을 사용할지 결정 - OrderSpecifier<?> orderBy; + OrderSpecifier<?>[] orderBy; if ("popular".equalsIgnoreCase(sort)) { - orderBy = vote.totalVoteCount.desc(); + orderBy = new OrderSpecifier<?>[]{ + vote.totalVoteCount.desc(), + vote.createdAt.desc(), + vote.id.desc() + }; } else { // 기본값 또는 latest 정렬 - orderBy = vote.createdAt.desc(); + orderBy = new OrderSpecifier<?>[]{ + vote.createdAt.desc(), + vote.id.desc() + }; } // 2. cursor 값에 따라 Predicate를 생성 @@ -52,10 +63,19 @@ public List<Vote> findVotesByCursor(String category, String sort, String cursor, String[] parts = cursor.split("_"); Integer cursorTotalVoteCount = Integer.parseInt(parts[0]); LocalDateTime cursorCreatedAt = LocalDateTime.parse(parts[1]); + Long cursorId = Long.parseLong(parts[2]); cursorPredicate = vote.totalVoteCount.lt(cursorTotalVoteCount) - .or(vote.totalVoteCount.eq(cursorTotalVoteCount).and(vote.createdAt.lt(cursorCreatedAt))); + .or(vote.totalVoteCount.eq(cursorTotalVoteCount) + .and(vote.createdAt.lt(cursorCreatedAt))) + .or(vote.totalVoteCount.eq(cursorTotalVoteCount) + .and(vote.createdAt.eq(cursorCreatedAt)) + .and(vote.id.lt(cursorId))); } else { // latest - cursorPredicate = vote.createdAt.lt(LocalDateTime.parse(cursor)); + String[] parts = cursor.split("_"); + LocalDateTime cursorCreatedAt = LocalDateTime.parse(parts[0]); + Long cursorId = Long.parseLong(parts[1]); + cursorPredicate = vote.createdAt.lt(cursorCreatedAt) + .or(vote.createdAt.eq(cursorCreatedAt).and(vote.id.lt(cursorId))); } } @@ -63,12 +83,28 @@ public List<Vote> findVotesByCursor(String category, String sort, String cursor, whereConditions.add(cursorPredicate); } - return queryFactory - .selectFrom(vote) + List<Long> voteIds = queryFactory + .select(vote.id) + .from(vote) .where(whereConditions.toArray(new Predicate[0])) .orderBy(orderBy) .limit(size + 1) // 다음 페이지 존재 여부 확인 .fetch(); + + if (voteIds.isEmpty()) { + return new ArrayList<>(); + } + + return queryFactory + .selectFrom(vote) + .distinct() + .leftJoin(vote.member, member).fetchJoin() + .leftJoin(member.profile, memberProfile).fetchJoin() + .leftJoin(vote.commentGroup, commentGroup).fetchJoin() + .leftJoin(vote.voteOptions, voteOption).fetchJoin() + .where(vote.id.in(voteIds)) + .orderBy(orderBy) + .fetch(); } @Override @@ -126,4 +162,4 @@ public Optional<Vote> findTrendingVote(LocalDateTime from, LocalDateTime to) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 1e8d8df..5632c57 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -381,9 +381,9 @@ public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String categ votes.remove(votes.size() - 1); Vote lastVote = votes.get(votes.size() - 1); if ("popular".equalsIgnoreCase(sort)) { - nextCursor = lastVote.getTotalVoteCount() + "_" + lastVote.getCreatedAt().toString(); + nextCursor = lastVote.getTotalVoteCount() + "_" + lastVote.getCreatedAt() + "_" + lastVote.getId(); } else { // latest - nextCursor = lastVote.getCreatedAt().toString(); + nextCursor = lastVote.getCreatedAt() + "_" + lastVote.getId(); } } diff --git a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java index f043bc4..c49e79c 100644 --- a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java +++ b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java @@ -8,6 +8,10 @@ import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.VoteRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -22,6 +26,7 @@ import java.time.LocalDateTime; import java.util.Arrays; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -38,6 +43,8 @@ public class VoteControllerTest { @Autowired private MemberRepository memberRepository; @Autowired private MemberProfileRepository memberProfileRepository; @Autowired private CommentGroupRepository commentGroupRepository; + @Autowired private EntityManager entityManager; + @Autowired private EntityManagerFactory entityManagerFactory; @BeforeEach void setUp() { @@ -161,4 +168,28 @@ void getHotIssueVote_SameTotalVoteCount_NewerIsHotIssue() throws Exception { .andExpect(jsonPath("$.totalParticipants").value(50)) .andExpect(jsonPath("$.createdBy").value("테스터3닉네임")); } + + @Test + @DisplayName("투표 목록 조회 시 목록 데이터와 연관 데이터를 고정된 쿼리 수로 조회한다.") + void getVotes_QueryCountIsStable() throws Exception { + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.setStatisticsEnabled(true); + entityManager.flush(); + entityManager.clear(); + statistics.clear(); + + mockMvc.perform(get("/votes") + .param("category", "ALL") + .param("sort", "latest") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.votes.length()").value(2)) + .andExpect(jsonPath("$.votes[0].nickname").value("테스터2닉네임")) + .andExpect(jsonPath("$.votes[1].nickname").value("테스터1닉네임")) + .andExpect(jsonPath("$.votes[1].options.length()").value(2)) + .andExpect(jsonPath("$.has_next_page").value(false)); + + assertThat(statistics.getPrepareStatementCount()).isEqualTo(2); + } } From 4634fe90946be07a6351318936528775c312fbfe Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Mon, 18 May 2026 15:23:43 +0900 Subject: [PATCH 37/43] =?UTF-8?q?fix:=20=ED=88=AC=ED=91=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/VoteService/VoteServiceImpl.java | 65 +++++++++++++++++++ .../controller/VoteControllerTest.java | 57 ++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 5632c57..aa6b594 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -372,6 +372,8 @@ public Long createVote(Long userId, VoteCreateRequest request) { @Override public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String category, String sort, String cursor, int size) { + validateVoteListRequest(category, sort, cursor, size); + List<Vote> votes = voteRepository.findVotesByCursor(category, sort, cursor, size); boolean hasNext = votes.size() > size; @@ -436,6 +438,69 @@ public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String categ .build(); } + private void validateVoteListRequest(String category, String sort, String cursor, int size) { + if (size < 1) { + throw new ApiException("size는 1 이상이어야 합니다.", HttpStatus.BAD_REQUEST); + } + + validateCategory(category); + validateSort(sort); + validateCursor(sort, cursor); + } + + private void validateCategory(String category) { + if (category == null || category.isBlank()) { + throw new ApiException("category는 ALL, FOOD, LOVE, ETC 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + + try { + VoteCategory.valueOf(category.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ApiException("category는 ALL, FOOD, LOVE, ETC 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + } + + private void validateSort(String sort) { + if (sort == null || sort.isBlank()) { + throw new ApiException("sort는 latest 또는 popular 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + + if (!"latest".equalsIgnoreCase(sort) && !"popular".equalsIgnoreCase(sort)) { + throw new ApiException("sort는 latest 또는 popular 중 하나여야 합니다.", HttpStatus.BAD_REQUEST); + } + } + + private void validateCursor(String sort, String cursor) { + if (cursor == null) { + return; + } + + if (cursor.isBlank()) { + throw new ApiException("cursor 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST); + } + + try { + if ("popular".equalsIgnoreCase(sort)) { + String[] parts = cursor.split("_"); + if (parts.length != 3) { + throw new IllegalArgumentException(); + } + Integer.parseInt(parts[0]); + LocalDateTime.parse(parts[1]); + Long.parseLong(parts[2]); + } else { + String[] parts = cursor.split("_"); + if (parts.length != 2) { + throw new IllegalArgumentException(); + } + LocalDateTime.parse(parts[0]); + Long.parseLong(parts[1]); + } + } catch (RuntimeException e) { + throw new ApiException("cursor 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST); + } + } + @Override @Transactional public void deleteVote(Long userId, Long voteId) { diff --git a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java index c49e79c..30bf1fd 100644 --- a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java +++ b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java @@ -192,4 +192,61 @@ void getVotes_QueryCountIsStable() throws Exception { assertThat(statistics.getPrepareStatementCount()).isEqualTo(2); } + + @Test + @DisplayName("투표 목록 조회 시 size가 1보다 작으면 400 Bad Request를 반환한다.") + void getVotes_InvalidSize_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("size", "0") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("size는 1 이상이어야 합니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 잘못된 category는 400 Bad Request를 반환한다.") + void getVotes_InvalidCategory_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("category", "INVALID") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("category는 ALL, FOOD, LOVE, ETC 중 하나여야 합니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 잘못된 sort는 400 Bad Request를 반환한다.") + void getVotes_InvalidSort_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("sort", "oldest") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("sort는 latest 또는 popular 중 하나여야 합니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 latest cursor 형식이 잘못되면 400 Bad Request를 반환한다.") + void getVotes_InvalidLatestCursor_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("sort", "latest") + .param("cursor", "2026-05-18T13:00:00") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("cursor 형식이 올바르지 않습니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("투표 목록 조회 시 popular cursor 형식이 잘못되면 400 Bad Request를 반환한다.") + void getVotes_InvalidPopularCursor_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes") + .param("sort", "popular") + .param("cursor", "100_2026-05-18T13:00:00") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("cursor 형식이 올바르지 않습니다.")) + .andExpect(jsonPath("$.status").value(400)); + } } From fcb690b8c888e08b64f47b06509830fda6efb947 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Tue, 19 May 2026 16:37:15 +0900 Subject: [PATCH 38/43] =?UTF-8?q?feat=20:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=82=B4=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/domain/enums/PointType.java | 3 ++- .../service/PointService/PointService.java | 1 + .../PointService/PointServiceImpl.java | 19 +++++++++++++ .../TitleService/TitleServiceImpl.java | 4 +++ .../PointService/PointServiceImplTest.java | 27 ++++++++++++++++++- .../TitleService/TitleServiceImplTest.java | 7 +++++ 6 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/valanse/valanse/domain/enums/PointType.java b/src/main/java/com/valanse/valanse/domain/enums/PointType.java index 37a18e1..9acca90 100644 --- a/src/main/java/com/valanse/valanse/domain/enums/PointType.java +++ b/src/main/java/com/valanse/valanse/domain/enums/PointType.java @@ -5,5 +5,6 @@ public enum PointType { POST_CREATE, POST_VOTED, HOT_ISSUE, - SIGN_UP + SIGN_UP, + TITLE_PURCHASE } diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointService.java b/src/main/java/com/valanse/valanse/service/PointService/PointService.java index 5f476e5..bf62b2e 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointService.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointService.java @@ -5,5 +5,6 @@ public interface PointService { void givePoint(Long memberId, PointType type); + void recordPointUsage(Long memberId, long amount, PointType type); PointHistoryResponse getPointHistory(Long memberId); } diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java index 26bdf0a..05850a7 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java @@ -63,6 +63,7 @@ public void givePoint(Long memberId, PointType type) { } case POST_VOTED -> POST_VOTED_POINT; case HOT_ISSUE -> HOT_ISSUE_POINT; + case TITLE_PURCHASE -> throw new IllegalArgumentException("포인트 지급 타입이 아닙니다."); }; // 포인트가 0보다 클 때만 포인트 지급 및 히스토리 저장 @@ -79,6 +80,23 @@ public void givePoint(Long memberId, PointType type) { } } + @Override + public void recordPointUsage(Long memberId, long amount, PointType type) { + if (amount <= 0) { + throw new IllegalArgumentException("사용 포인트는 0보다 커야 합니다."); + } + + Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + PointHistory history = PointHistory.builder() + .member(member) + .amount(-amount) + .type(type) + .build(); + pointHistoryRepository.save(history); + } + @Override @Transactional(readOnly = true) public PointHistoryResponse getPointHistory(Long memberId) { @@ -117,6 +135,7 @@ private String getPointTypeDescription(PointType type) { case COMMENT_CREATE -> "댓글 작성"; case POST_VOTED -> "투표 참여"; case HOT_ISSUE -> "핫이슈"; + case TITLE_PURCHASE -> "칭호 구매"; }; } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 1ec0a17..5de9b36 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -7,6 +7,7 @@ import com.valanse.valanse.domain.Title; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.TitleAcquisitionType; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.dto.Title.TitleCreateRequest; import com.valanse.valanse.dto.Title.TitleCreateResponse; import com.valanse.valanse.dto.Title.TitleAdminResponse; @@ -20,6 +21,7 @@ import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.TitleRepository; +import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -40,6 +42,7 @@ public class TitleServiceImpl implements TitleService { private final MemberProfileRepository memberProfileRepository; private final MemberProfileTitleRepository memberProfileTitleRepository; private final TitleRepository titleRepository; + private final PointService pointService; @Override public TitleListResponse getTitleList(Long userId) { @@ -137,6 +140,7 @@ public TitlePurchaseResponse purchaseTitle(Long userId, Long titleId) { } profile.subtractPoint(title.getPrice()); + pointService.recordPointUsage(userId, title.getPrice(), PointType.TITLE_PURCHASE); memberProfileTitleRepository.save(MemberProfileTitle.builder() .memberProfile(profile) .title(title) diff --git a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java index 10debac..504fafc 100644 --- a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java @@ -20,8 +20,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -109,7 +111,8 @@ void getPointTypeDescription_AllTypes() { PointHistory.builder().id(2L).member(member).amount(5L).type(PointType.POST_CREATE).build(), PointHistory.builder().id(3L).member(member).amount(1L).type(PointType.COMMENT_CREATE).build(), PointHistory.builder().id(4L).member(member).amount(1L).type(PointType.POST_VOTED).build(), - PointHistory.builder().id(5L).member(member).amount(50L).type(PointType.HOT_ISSUE).build() + PointHistory.builder().id(5L).member(member).amount(50L).type(PointType.HOT_ISSUE).build(), + PointHistory.builder().id(6L).member(member).amount(-300L).type(PointType.TITLE_PURCHASE).build() ); when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) @@ -127,6 +130,28 @@ void getPointTypeDescription_AllTypes() { assertThat(items.get(2).typeDescription()).isEqualTo("댓글 작성"); assertThat(items.get(3).typeDescription()).isEqualTo("투표 참여"); assertThat(items.get(4).typeDescription()).isEqualTo("핫이슈"); + assertThat(items.get(5).typeDescription()).isEqualTo("칭호 구매"); + } + + @Test + @DisplayName("포인트 사용 내역 기록 - 칭호 구매") + void recordPointUsage_TitlePurchase() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .thenReturn(Optional.of(member)); + + // When + pointService.recordPointUsage(memberId, 300L, PointType.TITLE_PURCHASE); + + // Then + verify(pointHistoryRepository).save(argThat(history -> + history.getMember() == member && + history.getAmount().equals(-300L) && + history.getType() == PointType.TITLE_PURCHASE + )); } @Test diff --git a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java index c7b9868..86305f2 100644 --- a/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/TitleService/TitleServiceImplTest.java @@ -6,6 +6,7 @@ import com.valanse.valanse.domain.MemberProfileTitle; import com.valanse.valanse.domain.Title; import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.domain.enums.TitleAcquisitionType; import com.valanse.valanse.domain.enums.TitleTier; import com.valanse.valanse.dto.Title.TitleCreateRequest; @@ -14,6 +15,7 @@ import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.TitleRepository; +import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -50,6 +52,9 @@ class TitleServiceImplTest { @Mock private TitleRepository titleRepository; + @Mock + private PointService pointService; + private Member member; private MemberProfile profile; @@ -225,6 +230,7 @@ void setup() { assertThat(response.title()).isEqualTo("선택의 신"); assertThat(response.owned()).isTrue(); assertThat(response.remainingPoint()).isEqualTo(200L); + verify(pointService).recordPointUsage(1L, 300L, PointType.TITLE_PURCHASE); verify(memberProfileTitleRepository).save(org.mockito.ArgumentMatchers.argThat(savedTitle -> savedTitle.getMemberProfile() == profile && savedTitle.getTitle().getId().equals(3L) )); @@ -248,6 +254,7 @@ void setup() { assertThat(exception.getMessage()).isEqualTo("포인트가 부족합니다. (필요포인트 300P 필요)"); assertThat(profile.getPoint()).isEqualTo(100L); + verify(pointService, never()).recordPointUsage(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); verify(memberProfileTitleRepository, never()).save(org.mockito.ArgumentMatchers.any()); } From bd042461fbb5172ad772d08424e3d473926810d3 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Tue, 19 May 2026 16:56:33 +0900 Subject: [PATCH 39/43] =?UTF-8?q?fix:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=9E=94?= =?UTF-8?q?=EC=97=AC=20=EC=9E=AC=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/valanse/domain/PointHistory.java | 2 + .../PointHistory/PointHistoryResponse.java | 1 + .../PointService/PointServiceImpl.java | 43 +++++++++++++++++++ .../PointService/PointServiceImplTest.java | 20 ++++++++- 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/valanse/valanse/domain/PointHistory.java b/src/main/java/com/valanse/valanse/domain/PointHistory.java index 90e46a0..5803c6a 100644 --- a/src/main/java/com/valanse/valanse/domain/PointHistory.java +++ b/src/main/java/com/valanse/valanse/domain/PointHistory.java @@ -29,6 +29,8 @@ public class PointHistory { @Enumerated(EnumType.STRING) private PointType type; + private Long remainingPoint; + private LocalDateTime createdAt; @PrePersist diff --git a/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java index 0023188..9e75518 100644 --- a/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java +++ b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java @@ -10,6 +10,7 @@ public record PointHistoryResponse( public record PointHistoryItem( Long id, Long amount, + Long remainingPoint, PointType type, String typeDescription, String createdAt diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java index 05850a7..3070d52 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java @@ -15,7 +15,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -74,6 +76,7 @@ public void givePoint(Long memberId, PointType type) { PointHistory history = PointHistory.builder() .member(member) .amount(amount) + .remainingPoint(profile.getPoint()) .type(type) .build(); pointHistoryRepository.save(history); @@ -89,9 +92,13 @@ public void recordPointUsage(Long memberId, long amount, PointType type) { Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + MemberProfile profile = memberProfileRepository.findByMemberId(memberId) + .orElseThrow(() -> new IllegalArgumentException("프로필을 찾을 수 없습니다.")); + PointHistory history = PointHistory.builder() .member(member) .amount(-amount) + .remainingPoint(profile.getPoint()) .type(type) .build(); pointHistoryRepository.save(history); @@ -106,6 +113,7 @@ public PointHistoryResponse getPointHistory(Long memberId) { // 포인트 히스토리 조회 (최신순으로 정렬) List<PointHistory> histories = pointHistoryRepository.findByMemberId(memberId); + Map<Long, Long> fallbackRemainingPoints = calculateRemainingPoints(histories); // DTO로 변환 List<PointHistoryResponse.PointHistoryItem> historyItems = histories.stream() @@ -119,6 +127,9 @@ public PointHistoryResponse getPointHistory(Long memberId) { .map(history -> new PointHistoryResponse.PointHistoryItem( history.getId(), history.getAmount(), + history.getRemainingPoint() != null + ? history.getRemainingPoint() + : fallbackRemainingPoints.get(history.getId()), history.getType(), getPointTypeDescription(history.getType()), formatCreatedAt(history.getCreatedAt()) @@ -128,6 +139,38 @@ public PointHistoryResponse getPointHistory(Long memberId) { return new PointHistoryResponse(historyItems); } + private Map<Long, Long> calculateRemainingPoints(List<PointHistory> histories) { + Map<Long, Long> remainingPoints = new HashMap<>(); + long balance = 0L; + + List<PointHistory> oldestFirstHistories = histories.stream() + .sorted((h1, h2) -> { + if (h1.getCreatedAt() == null && h2.getCreatedAt() == null) { + return compareId(h1, h2); + } + if (h1.getCreatedAt() == null) return -1; + if (h2.getCreatedAt() == null) return 1; + + int createdAtCompare = h1.getCreatedAt().compareTo(h2.getCreatedAt()); + return createdAtCompare != 0 ? createdAtCompare : compareId(h1, h2); + }) + .toList(); + + for (PointHistory history : oldestFirstHistories) { + balance += history.getAmount(); + remainingPoints.put(history.getId(), balance); + } + + return remainingPoints; + } + + private int compareId(PointHistory h1, PointHistory h2) { + if (h1.getId() == null && h2.getId() == null) return 0; + if (h1.getId() == null) return -1; + if (h2.getId() == null) return 1; + return h1.getId().compareTo(h2.getId()); + } + private String getPointTypeDescription(PointType type) { return switch (type) { case SIGN_UP -> "회원가입"; diff --git a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java index 504fafc..f6739a4 100644 --- a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java @@ -1,9 +1,11 @@ package com.valanse.valanse.service.PointService; import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.PointHistory; import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.PointHistoryRepository; import org.junit.jupiter.api.DisplayName; @@ -21,7 +23,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,6 +33,9 @@ class PointServiceImplTest { @Mock private MemberRepository memberRepository; + @Mock + private MemberProfileRepository memberProfileRepository; + @Mock private PointHistoryRepository pointHistoryRepository; @@ -45,24 +49,26 @@ void getPointHistory_Success() { Long memberId = 1L; Member member = Member.builder().id(memberId).build(); - LocalDateTime now = LocalDateTime.now(); List<PointHistory> histories = Arrays.asList( PointHistory.builder() .id(1L) .member(member) .amount(40L) + .remainingPoint(40L) .type(PointType.SIGN_UP) .build(), PointHistory.builder() .id(2L) .member(member) .amount(5L) + .remainingPoint(45L) .type(PointType.POST_CREATE) .build(), PointHistory.builder() .id(3L) .member(member) .amount(1L) + .remainingPoint(46L) .type(PointType.COMMENT_CREATE) .build() ); @@ -81,6 +87,7 @@ void getPointHistory_Success() { PointHistoryResponse.PointHistoryItem firstItem = response.pointHistory().get(0); assertThat(firstItem.amount()).isEqualTo(40L); + assertThat(firstItem.remainingPoint()).isEqualTo(40L); assertThat(firstItem.type()).isEqualTo(PointType.SIGN_UP); assertThat(firstItem.typeDescription()).isEqualTo("회원가입"); } @@ -139,9 +146,15 @@ void recordPointUsage_TitlePurchase() { // Given Long memberId = 1L; Member member = Member.builder().id(memberId).build(); + MemberProfile profile = MemberProfile.builder() + .member(member) + .point(700L) + .build(); when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) .thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(memberId)) + .thenReturn(Optional.of(profile)); // When pointService.recordPointUsage(memberId, 300L, PointType.TITLE_PURCHASE); @@ -150,6 +163,7 @@ void recordPointUsage_TitlePurchase() { verify(pointHistoryRepository).save(argThat(history -> history.getMember() == member && history.getAmount().equals(-300L) && + history.getRemainingPoint().equals(700L) && history.getType() == PointType.TITLE_PURCHASE )); } @@ -167,6 +181,7 @@ void getPointHistory_DateFormatting() { PointHistory mockHistory = mock(PointHistory.class); when(mockHistory.getId()).thenReturn(1L); when(mockHistory.getAmount()).thenReturn(40L); + when(mockHistory.getRemainingPoint()).thenReturn(40L); when(mockHistory.getType()).thenReturn(PointType.SIGN_UP); when(mockHistory.getCreatedAt()).thenReturn(testDateTime); @@ -188,6 +203,7 @@ void getPointHistory_DateFormatting() { // 날짜가 "2026-04-26 15:30:22" 형식으로 포맷되었는지 확인 assertThat(item.createdAt()).isEqualTo("2026-04-26 15:30:22"); assertThat(item.amount()).isEqualTo(40L); + assertThat(item.remainingPoint()).isEqualTo(40L); assertThat(item.type()).isEqualTo(PointType.SIGN_UP); assertThat(item.typeDescription()).isEqualTo("회원가입"); } From 4e80793254ab9baa000b8a9981509ccd4e406fd8 Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Wed, 20 May 2026 17:05:08 +0900 Subject: [PATCH 40/43] =?UTF-8?q?feat=20:=20=EB=8C=93=EA=B8=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20=EA=B0=92=EC=97=90=20=EC=B9=AD?= =?UTF-8?q?=ED=98=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../valanse/dto/Comment/CommentReplyResponseDto.java | 3 ++- .../valanse/dto/Comment/CommentResponseDto.java | 4 +++- .../CommentRepositoryImpl.java | 5 +++++ .../repository/MemberProfileTitleRepository.java | 2 ++ .../service/CommentService/CommentServiceImpl.java | 11 ++++++++++- .../CommentService/CommentServiceImplTest.java | 2 ++ 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java b/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java index 1aae679..02cd181 100644 --- a/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java @@ -12,6 +12,7 @@ public class CommentReplyResponseDto { private Long id; private String nickname; + private String title; private LocalDateTime createdAt; private String content; private int likeCount; @@ -22,4 +23,4 @@ public class CommentReplyResponseDto { private Long daysAgo; private Long hoursAgo; private boolean canDelete; -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java b/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java index 5170b41..ed97b7a 100644 --- a/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java @@ -13,6 +13,7 @@ public class CommentResponseDto { private Long commentId; private Long voteId; private String nickname; + private String title; private LocalDateTime commentCreatedAt; // 추가 private LocalDateTime voteCreatedAt; // 추가 private String content; @@ -25,7 +26,7 @@ public class CommentResponseDto { private Boolean canDelete; @QueryProjection - public CommentResponseDto(Long commentId, Long voteId, String nickname, + public CommentResponseDto(Long commentId, Long voteId, String nickname, String title, LocalDateTime commentCreatedAt, LocalDateTime voteCreatedAt, // 추가 String content, Integer likeCount, Integer replyCount, LocalDateTime deletedAt, String voteOptionLabel, Long daysAgo, Long hoursAgo, @@ -33,6 +34,7 @@ public CommentResponseDto(Long commentId, Long voteId, String nickname, this.commentId = commentId; this.voteId = voteId; this.nickname = nickname; + this.title = title; this.commentCreatedAt = commentCreatedAt; this.voteCreatedAt = voteCreatedAt; this.content = content; diff --git a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java index 76df830..d375a6c 100644 --- a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java @@ -34,6 +34,8 @@ public Slice<CommentResponseDto> findCommentsByVoteIdSlice(Long voteId, String s QMemberVoteOption mvo = QMemberVoteOption.memberVoteOption; QVoteOption voteOption = QVoteOption.voteOption; QMemberProfile profile = QMemberProfile.memberProfile; + QMemberProfileTitle memberProfileTitle = QMemberProfileTitle.memberProfileTitle; + QTitle title = QTitle.title; NumberTemplate<Long> totalHoursAgo = Expressions.numberTemplate( Long.class, @@ -67,6 +69,7 @@ public Slice<CommentResponseDto> findCommentsByVoteIdSlice(Long voteId, String s comment.id, vote.id, profile.nickname, + title.name, comment.createdAt, vote.createdAt, comment.content, @@ -81,6 +84,8 @@ public Slice<CommentResponseDto> findCommentsByVoteIdSlice(Long voteId, String s .from(comment) .join(comment.member, member) .leftJoin(member.profile, profile) + .leftJoin(profile.memberProfileTitles, memberProfileTitle).on(memberProfileTitle.equipped.isTrue()) + .leftJoin(memberProfileTitle.title, title) .join(comment.commentGroup.vote, vote) .leftJoin(mvo).on(mvo.member.eq(member).and(mvo.vote.eq(vote))) .leftJoin(mvo.voteOption, voteOption) diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java index 5149de9..65942f0 100644 --- a/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileTitleRepository.java @@ -13,6 +13,8 @@ public interface MemberProfileTitleRepository extends JpaRepository<MemberProfil List<MemberProfileTitle> findAllByMemberProfileMemberIdAndEquippedTrue(Long memberId); + Optional<MemberProfileTitle> findByMemberProfileMemberIdAndEquippedTrue(Long memberId); + List<MemberProfileTitle> findAllByMemberProfileMemberId(Long memberId); List<MemberProfileTitle> findAllByTitleIdAndEquippedTrue(Long titleId); diff --git a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java index a403f8d..bdd271a 100644 --- a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java @@ -36,6 +36,7 @@ public class CommentServiceImpl implements CommentService { private final CommentGroupRepository commentGroupRepository; private final CommentRepository commentRepository; private final MemberProfileRepository memberProfileRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; private final PointService pointService; @Override @@ -227,6 +228,7 @@ public List<CommentReplyResponseDto> getReplies(Member loginUser, Long voteId, L return CommentReplyResponseDto.builder() .id(reply.getId()) .nickname(profile.getNickname()) + .title(getEquippedTitleName(reply.getMember().getId())) .createdAt(reply.getCreatedAt()) .content(reply.getContent()) .likeCount(reply.getLikeCount()) @@ -240,4 +242,11 @@ public List<CommentReplyResponseDto> getReplies(Member loginUser, Long voteId, L }) .collect(Collectors.toList()); } -} \ No newline at end of file + + private String getEquippedTitleName(Long memberId) { + return memberProfileTitleRepository.findByMemberProfileMemberIdAndEquippedTrue(memberId) + .map(MemberProfileTitle::getTitle) + .map(Title::getName) + .orElse(null); + } +} diff --git a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java index 3533255..41913f2 100644 --- a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java @@ -37,6 +37,8 @@ class CommentServiceImplTest { @Mock private MemberProfileRepository memberProfileRepository; @Mock + private MemberProfileTitleRepository memberProfileTitleRepository; + @Mock private PointService pointService; private Member member; From 0585a88ed5b8aa05a9888bc7862473275ab4bedd Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Wed, 20 May 2026 17:31:12 +0900 Subject: [PATCH 41/43] =?UTF-8?q?feat=20:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20=EA=B0=92=EC=97=90=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EC=B9=AD=ED=98=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/Vote/HotIssueVoteResponse.java | 3 ++- .../valanse/dto/Vote/VoteDetailResponse.java | 3 ++- .../valanse/dto/Vote/VoteListResponse.java | 3 ++- .../valanse/dto/Vote/VoteResponseDto.java | 8 ++++++- .../service/VoteService/VoteServiceImpl.java | 24 +++++++++++++++++-- .../VoteService/VoteServiceImplTest.java | 1 + 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java index c2fc4d1..846ad70 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java @@ -21,7 +21,8 @@ public class HotIssueVoteResponse { private String category; // 가장 투표 참여 횟수가 많은 투표의 카테고리 private Integer totalParticipants; // 가장 투표 참여 횟수가 많은 투표의 총 투표 수 private String createdBy; // 가장 투표 참여 횟수가 많은 투표를 생성한 사람의 닉네임 + private String creatorTitle; private LocalDateTime createdAt; // 투표 생성 날짜 private PinType pinType; // 고정 여부 private List<HotIssueVoteOptionDto> options; // 투표 옵션 리스트 (content, vote_count) -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java index 766b9bc..d0e25da 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java @@ -16,6 +16,7 @@ public class VoteDetailResponse { private VoteCategory category; private Integer totalVoteCount; private String creatorNickname; // 생성자의 닉네임을 원한다고 가정 + private String creatorTitle; private LocalDateTime createdAt; private List<VoteOptionDto> options; // 투표 옵션을 위한 DTO // --- 추가될 필드 --- @@ -31,4 +32,4 @@ public static class VoteOptionDto { private Integer voteCount; private String label; } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java index fdc1808..228b909 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java @@ -33,6 +33,7 @@ public static class VoteDto { private String category; // ENUM 이름을 String으로 반환 private Long member_id; private String nickname; + private String member_title; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDateTime created_at; private Integer total_vote_count; @@ -50,4 +51,4 @@ public static class VoteOptionListDto { private Long id; private String content; } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java index e3cb3b6..b8af559 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java @@ -14,18 +14,24 @@ public class VoteResponseDto { private String category; private int totalVoteCount; private String createdAt; + private String creatorTitle; private List<String> options; public VoteResponseDto(Vote vote) { + this(vote, null); + } + + public VoteResponseDto(Vote vote, String creatorTitle) { this.voteId = vote.getId(); this.title = vote.getTitle(); this.content = vote.getContent(); // content 필드 추가 this.category = vote.getCategory().name(); // enum to string this.totalVoteCount = vote.getTotalVoteCount(); this.createdAt = vote.getCreatedAt().toLocalDate().toString(); + this.creatorTitle = creatorTitle; this.options = vote.getVoteOptions() .stream() .map(option -> option.getContent()) // VoteOption에서 content 추출 .collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 1e8d8df..c09fd44 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -33,6 +33,7 @@ public class VoteServiceImpl implements VoteService { private final VoteOptionRepository voteOptionRepository; private final MemberVoteOptionRepository memberVoteOptionRepository; private final CommentGroupRepository commentGroupRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; private final PointService pointService; //작은 민지가 구현한 것 @@ -58,7 +59,9 @@ public List<VoteResponseDto> getMyCreatedVotes(Long memberId, String sort, VoteC voteRepository.findAllByMemberAndCategoryOrderByCreatedAtAsc(member, category); } - return votes.stream().map(VoteResponseDto::new).collect(Collectors.toList()); + return votes.stream() + .map(vote -> new VoteResponseDto(vote, getEquippedTitleName(vote.getMember()))) + .collect(Collectors.toList()); } @Override @@ -83,7 +86,9 @@ public List<VoteResponseDto> getMyVotedVotes(Long memberId, String sort, VoteCat voteRepository.findAllByMemberVotedAndCategoryOrderByCreatedAtAsc(member, category); } - return votes.stream().map(VoteResponseDto::new).collect(Collectors.toList()); + return votes.stream() + .map(vote -> new VoteResponseDto(vote, getEquippedTitleName(vote.getMember()))) + .collect(Collectors.toList()); } //여기서부터 영서 코드 @@ -257,6 +262,7 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { // MemberProfile의 nickname을 가져오도록 수정 String creatorNickname = null; + String creatorTitle = getEquippedTitleName(vote.getMember()); if (vote.getMember() != null && vote.getMember().getProfile() != null) { creatorNickname = vote.getMember().getProfile().getNickname(); } @@ -304,6 +310,7 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { .category(vote.getCategory()) .totalVoteCount(vote.getTotalVoteCount()) .creatorNickname(creatorNickname) // 수정된 닉네임 사용 + .creatorTitle(creatorTitle) .createdAt(vote.getCreatedAt()) .options(optionDtos) .hasVoted(hasVoted) // 새로운 필드 값 설정 @@ -420,6 +427,7 @@ public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String categ .category(vote.getCategory().name()) .member_id(vote.getMember() != null ? vote.getMember().getId() : null) .nickname(creatorNickname) + .member_title(getEquippedTitleName(vote.getMember())) .created_at(vote.getCreatedAt()) .total_vote_count(vote.getTotalVoteCount()) .total_comment_count(totalCommentCount) @@ -511,10 +519,22 @@ private HotIssueVoteResponse getHotIssueVoteResponse(Vote hotIssueVote) { .category(hotIssueVote.getCategory() != null ? hotIssueVote.getCategory().name() : null) // 카테고리 설정 .totalParticipants(hotIssueVote.getTotalVoteCount()) // 총 참여자 수 설정 .createdBy(createdByNickname) // 생성자 닉네임 설정 + .creatorTitle(getEquippedTitleName(hotIssueVote.getMember())) .createdAt(hotIssueVote.getCreatedAt()) // 추가된 부분: createdAt 설정 .pinType(hotIssueVote.getPinType()) .options(options) // 옵션 리스트 설정 .build(); } + private String getEquippedTitleName(Member member) { + if (member == null || member.getId() == null) { + return null; + } + + return memberProfileTitleRepository.findByMemberProfileMemberIdAndEquippedTrue(member.getId()) + .map(MemberProfileTitle::getTitle) + .map(Title::getName) + .orElse(null); + } + } diff --git a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java index bbd62ba..16f4883 100644 --- a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java @@ -48,6 +48,7 @@ class VoteServiceImplTest { @Mock private CommentGroupRepository commentGroupRepository; @Mock private VoteOptionRepository voteOptionRepository; @Mock private MemberProfileRepository memberProfileRepository; + @Mock private MemberProfileTitleRepository memberProfileTitleRepository; @Mock private PointService pointService; // ────────────────────────────────────────────── From 29ce811e96451d6a3ce7066a98a98aecd134615d Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Wed, 20 May 2026 22:57:07 +0900 Subject: [PATCH 42/43] =?UTF-8?q?fix=20:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20point=20type=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=83=9D=EA=B8=B0=EB=8A=94=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/valanse/valanse/domain/PointHistory.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/valanse/valanse/domain/PointHistory.java b/src/main/java/com/valanse/valanse/domain/PointHistory.java index 5803c6a..6d4c9f1 100644 --- a/src/main/java/com/valanse/valanse/domain/PointHistory.java +++ b/src/main/java/com/valanse/valanse/domain/PointHistory.java @@ -6,6 +6,8 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import java.time.LocalDateTime; @@ -27,6 +29,8 @@ public class PointHistory { private Long amount; @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.VARCHAR) + @Column(nullable = false, length = 30) private PointType type; private Long remainingPoint; From 0878a9bf94eccd1a5d0af6dde8f8f90f46bf3daa Mon Sep 17 00:00:00 2001 From: 5solbin <dhrhfqls@naver.com> Date: Thu, 21 May 2026 15:31:54 +0900 Subject: [PATCH 43/43] =?UTF-8?q?feat=20:=20=EA=B8=B0=EB=B3=B8=20=EC=B9=AD?= =?UTF-8?q?=ED=98=B8=20=EC=9E=90=EB=8F=99=20=EC=9E=A5=EC=B0=A9=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberProfileServiceImpl.java | 27 +++++++++++- .../TitleService/TitleServiceImpl.java | 25 +++++++---- .../MemberProfileServiceImplTest.java | 44 +++++++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java index 32cee41..6016354 100644 --- a/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImpl.java @@ -3,12 +3,16 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.MemberProfile; import com.valanse.valanse.domain.MemberProfileTitle; +import com.valanse.valanse.domain.Title; import com.valanse.valanse.domain.enums.PointType; +import com.valanse.valanse.domain.enums.TitleAcquisitionType; import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.dto.MemberProfile.MemberProfileResponse; import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.TitleRepository; import com.valanse.valanse.service.PointService.PointService; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; @@ -25,6 +29,8 @@ public class MemberProfileServiceImpl implements MemberProfileService { private final MemberRepository memberRepository; private final MemberProfileRepository memberProfileRepository; + private final MemberProfileTitleRepository memberProfileTitleRepository; + private final TitleRepository titleRepository; private final PointService pointService; @Override @@ -77,7 +83,8 @@ public void saveOrUpdateProfile(MemberProfileRequest dto) { .mbti(dto.mbti()) .build(); - memberProfileRepository.save(newProfile); + MemberProfile savedProfile = memberProfileRepository.save(newProfile); + grantAndEquipDefaultTitle(savedProfile != null ? savedProfile : newProfile); // 신규 프로필 생성 시 회원가입 포인트 지급 pointService.givePoint(userId, PointType.SIGN_UP); @@ -236,4 +243,22 @@ private String getEquippedTitleName(MemberProfile profile) { .orElse(null); } + private void grantAndEquipDefaultTitle(MemberProfile profile) { + Optional<Title> defaultTitle = titleRepository + .findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT); + + if (defaultTitle == null || defaultTitle.isEmpty()) { + return; + } + + MemberProfileTitle profileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(defaultTitle.get()) + .equipped(true) + .build(); + + memberProfileTitleRepository.save(profileTitle); + profile.getMemberProfileTitles().add(profileTitle); + } + } diff --git a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java index 5de9b36..f226c02 100644 --- a/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/TitleService/TitleServiceImpl.java @@ -51,6 +51,7 @@ public TitleListResponse getTitleList(Long userId) { List<Title> titles = titleRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); List<MemberProfileTitle> profileTitles = memberProfileTitleRepository.findAllByMemberProfileMemberId(userId); + boolean hasEquippedTitle = profileTitles.stream().anyMatch(MemberProfileTitle::isEquipped); Map<Long, MemberProfileTitle> ownedTitleMap = profileTitles.stream() .collect(Collectors.toMap( profileTitle -> profileTitle.getTitle().getId(), @@ -58,14 +59,22 @@ public TitleListResponse getTitleList(Long userId) { (current, ignored) -> current )); - List<MemberProfileTitle> defaultTitlesToSave = titles.stream() - .filter(Title::isDefaultTitle) - .filter(title -> !ownedTitleMap.containsKey(title.getId())) - .map(title -> MemberProfileTitle.builder() - .memberProfile(profile) - .title(title) - .build()) - .toList(); + List<MemberProfileTitle> defaultTitlesToSave = new ArrayList<>(); + for (Title title : titles) { + if (!title.isDefaultTitle() || ownedTitleMap.containsKey(title.getId())) { + continue; + } + + MemberProfileTitle profileTitle = MemberProfileTitle.builder() + .memberProfile(profile) + .title(title) + .equipped(!hasEquippedTitle) + .build(); + if (!hasEquippedTitle) { + hasEquippedTitle = true; + } + defaultTitlesToSave.add(profileTitle); + } if (!defaultTitlesToSave.isEmpty()) { memberProfileTitleRepository.saveAll(defaultTitlesToSave) diff --git a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java index 4db001d..59a709c 100644 --- a/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/MemberProfileService/MemberProfileServiceImplTest.java @@ -7,7 +7,9 @@ import com.valanse.valanse.domain.enums.*; import com.valanse.valanse.dto.MemberProfile.MemberProfileRequest; import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberProfileTitleRepository; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.TitleRepository; import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -38,6 +40,13 @@ class MemberProfileServiceImplTest { @Mock private MemberRepository memberRepository; + + @Mock + private MemberProfileTitleRepository memberProfileTitleRepository; + + @Mock + private TitleRepository titleRepository; + @Mock private PointService pointService; @@ -89,6 +98,41 @@ void setupSecurityContext() { verify(memberProfileRepository, times(1)).save(any(MemberProfile.class)); } + @Test + @DisplayName("신규 프로필 생성 시 기본 칭호를 지급하고 장착한다") + void 신규프로필_기본칭호_지급_및_장착() { + MemberProfileRequest request = new MemberProfileRequest( + "새싹유저", + Gender.MALE, + Age.TWENTY, + MbtiIe.E, + MbtiTf.T, + "ENTP" + ); + Title defaultTitle = Title.builder() + .id(1L) + .code("DEFAULT_SEED") + .name("밸런스 새싹") + .tier(TitleTier.BASIC) + .acquisitionType(TitleAcquisitionType.DEFAULT) + .build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.empty()); + when(memberProfileRepository.existsByNicknameAndDeletedAtIsNull("새싹유저")).thenReturn(false); + when(memberProfileRepository.save(any(MemberProfile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(titleRepository.findFirstByActiveTrueAndAcquisitionTypeOrderByDisplayOrderAscIdAsc(TitleAcquisitionType.DEFAULT)) + .thenReturn(Optional.of(defaultTitle)); + + memberProfileService.saveOrUpdateProfile(request); + + verify(memberProfileTitleRepository).save(argThat(profileTitle -> + profileTitle.getTitle().equals(defaultTitle) + && profileTitle.isEquipped() + && profileTitle.getMemberProfile().getNickname().equals("새싹유저") + )); + } + @Test @DisplayName("[핵심] 활성 회원이 사용 중인 닉네임은 중복으로 막혀야 한다") void 활성회원_닉네임_중복_차단() {