From d5a6348010eff4b472b1bab2381efd117950fb2b Mon Sep 17 00:00:00 2001 From: Junad-Park Date: Sat, 17 Jan 2026 17:33:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=8C=80=ED=91=9C=20=EB=B1=83?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/domain/service/BadgeService.java | 14 ++++++ .../badge/domain/usecase/BadgeUseCase.java | 14 ++++++ .../badge/persistence/entity/UserBadge.java | 4 ++ .../repository/UserBadgeRepository.java | 5 ++ .../badge/presentation/api/BadgeApi.java | 21 ++++++++ .../controller/BadgeController.java | 16 +++++++ .../request/RepresentativeBadgeRequest.java | 12 +++++ .../global/exception/ExceptionCode.java | 1 + .../im/toduck/builder/TestFixtureBuilder.java | 5 ++ .../domain/service/BadgeServiceTest.java | 48 ++++++++++++++++++- .../toduck/fixtures/badge/BadgeFixtures.java | 9 ++++ 11 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/main/java/im/toduck/domain/badge/presentation/dto/request/RepresentativeBadgeRequest.java diff --git a/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java b/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java index baed672d..dc95f66d 100644 --- a/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java +++ b/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java @@ -60,4 +60,18 @@ public UserBadge grantBadge(final User user, final BadgeCode badgeCode) { public List getUnseenBadges(final User user) { return userBadgeRepository.findAllByUserAndIsSeenFalse(user); } + + @Transactional + public void setRepresentativeBadge(final User user, final Long badgeId) { + Badge badge = badgeRepository.findById(badgeId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_BADGE)); + + UserBadge newRepresentativeBadge = userBadgeRepository.findByUserAndBadge(user, badge) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_OWNED_BADGE)); + + userBadgeRepository.findByUserAndIsRepresentativeTrue(user) + .ifPresent(oldBadge -> oldBadge.updateRepresentativeStatus(false)); + + newRepresentativeBadge.updateRepresentativeStatus(true); + } } diff --git a/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java b/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java index c96953f6..0d09afbc 100644 --- a/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java +++ b/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java @@ -66,4 +66,18 @@ public List getNewBadges(final Long userId) { return responses; } + + /** + * 사용자의 대표 뱃지를 설정합니다. + * + * @param userId 뱃지를 설정할 사용자 ID + * @param badgeId 설정할 뱃지 ID + */ + @Transactional + public void setRepresentativeBadge(final Long userId, final Long badgeId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + badgeService.setRepresentativeBadge(user, badgeId); + } } diff --git a/src/main/java/im/toduck/domain/badge/persistence/entity/UserBadge.java b/src/main/java/im/toduck/domain/badge/persistence/entity/UserBadge.java index 13cc3448..a208854a 100644 --- a/src/main/java/im/toduck/domain/badge/persistence/entity/UserBadge.java +++ b/src/main/java/im/toduck/domain/badge/persistence/entity/UserBadge.java @@ -51,4 +51,8 @@ private UserBadge(User user, Badge badge) { public void markAsSeen() { this.isSeen = true; } + + public void updateRepresentativeStatus(boolean isRepresentative) { + this.isRepresentative = isRepresentative; + } } diff --git a/src/main/java/im/toduck/domain/badge/persistence/repository/UserBadgeRepository.java b/src/main/java/im/toduck/domain/badge/persistence/repository/UserBadgeRepository.java index 38deb37a..dda11a2b 100644 --- a/src/main/java/im/toduck/domain/badge/persistence/repository/UserBadgeRepository.java +++ b/src/main/java/im/toduck/domain/badge/persistence/repository/UserBadgeRepository.java @@ -1,6 +1,7 @@ package im.toduck.domain.badge.persistence.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -14,4 +15,8 @@ public interface UserBadgeRepository extends JpaRepository { List findAllByUser(User user); List findAllByUserAndIsSeenFalse(User user); + + Optional findByUserAndIsRepresentativeTrue(User user); + + Optional findByUserAndBadge(User user, Badge badge); } diff --git a/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java b/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java index 983b9de4..33a14d26 100644 --- a/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java +++ b/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java @@ -1,17 +1,23 @@ package im.toduck.domain.badge.presentation.api; import java.util.List; +import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; import im.toduck.domain.badge.common.dto.response.BadgeResponse; +import im.toduck.domain.badge.presentation.dto.request.RepresentativeBadgeRequest; +import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; import im.toduck.global.annotation.swagger.ApiResponseExplanations; import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; +import im.toduck.global.exception.ExceptionCode; import im.toduck.global.presentation.ApiResponse; import im.toduck.global.security.authentication.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; @Tag(name = "Badges", description = "뱃지 관련 API") public interface BadgeApi { @@ -25,4 +31,19 @@ public interface BadgeApi { ResponseEntity>> getNewBadges( @AuthenticationPrincipal CustomUserDetails userDetails ); + + @Operation(summary = "대표 뱃지 설정", description = "사용자의 대표 뱃지를 설정하거나 변경합니다.") + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "대표 뱃지 설정 성공" + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_BADGE), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_OWNED_BADGE) + } + ) + ResponseEntity>> updateRepresentativeBadge( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody RepresentativeBadgeRequest request + ); } diff --git a/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java b/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java index d9988c32..e492b972 100644 --- a/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java +++ b/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java @@ -1,19 +1,24 @@ package im.toduck.domain.badge.presentation.controller; import java.util.List; +import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import im.toduck.domain.badge.common.dto.response.BadgeResponse; import im.toduck.domain.badge.domain.usecase.BadgeUseCase; import im.toduck.domain.badge.presentation.api.BadgeApi; +import im.toduck.domain.badge.presentation.dto.request.RepresentativeBadgeRequest; import im.toduck.global.presentation.ApiResponse; import im.toduck.global.security.authentication.CustomUserDetails; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -32,4 +37,15 @@ public ResponseEntity>> getNewBadges( List responses = badgeUseCase.getNewBadges(userDetails.getUserId()); return ResponseEntity.ok(ApiResponse.createSuccess(responses)); } + + @Override + @PatchMapping("/representative") + @PreAuthorize("isAuthenticated()") + public ResponseEntity>> updateRepresentativeBadge( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody RepresentativeBadgeRequest request + ) { + badgeUseCase.setRepresentativeBadge(userDetails.getUserId(), request.badgeId()); + return ResponseEntity.ok(ApiResponse.createSuccessWithNoContent()); + } } diff --git a/src/main/java/im/toduck/domain/badge/presentation/dto/request/RepresentativeBadgeRequest.java b/src/main/java/im/toduck/domain/badge/presentation/dto/request/RepresentativeBadgeRequest.java new file mode 100644 index 00000000..6fbd43f3 --- /dev/null +++ b/src/main/java/im/toduck/domain/badge/presentation/dto/request/RepresentativeBadgeRequest.java @@ -0,0 +1,12 @@ +package im.toduck.domain.badge.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "대표 뱃지 설정 요청") +public record RepresentativeBadgeRequest( + @Schema(description = "설정할 뱃지 ID", example = "1") + @NotNull(message = "뱃지 ID는 필수입니다.") + Long badgeId +) { +} diff --git a/src/main/java/im/toduck/global/exception/ExceptionCode.java b/src/main/java/im/toduck/global/exception/ExceptionCode.java index 6233865d..0a97a163 100644 --- a/src/main/java/im/toduck/global/exception/ExceptionCode.java +++ b/src/main/java/im/toduck/global/exception/ExceptionCode.java @@ -127,6 +127,7 @@ public enum ExceptionCode { /* 413xx Badge */ NOT_FOUND_BADGE(HttpStatus.NOT_FOUND, 41301, "뱃지를 찾을 수 없습니다."), ALREADY_ACQUIRED_BADGE(HttpStatus.CONFLICT, 41302, "이미 획득한 뱃지입니다."), + NOT_OWNED_BADGE(HttpStatus.FORBIDDEN, 41303, "보유하지 않은 뱃지입니다."), /* 431xx schedule */ NOT_FOUND_SCHEDULE_RECORD(HttpStatus.NOT_FOUND, 43101, "일정 기록을 찾을 수 없습니다.", diff --git a/src/test/java/im/toduck/builder/TestFixtureBuilder.java b/src/test/java/im/toduck/builder/TestFixtureBuilder.java index bb9a7a9b..812d0835 100644 --- a/src/test/java/im/toduck/builder/TestFixtureBuilder.java +++ b/src/test/java/im/toduck/builder/TestFixtureBuilder.java @@ -143,4 +143,9 @@ public Badge buildBadge(final Badge badge) { public UserBadge buildUserBadge(final UserBadge userBadge) { return bs.userBadgeRepository().save(userBadge); } + + public UserBadge buildUserBadge(final UserBadge userBadge, boolean isRepresentative) { + ReflectionTestUtils.setField(userBadge, "isRepresentative", isRepresentative); + return bs.userBadgeRepository().save(userBadge); + } } diff --git a/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java b/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java index 269d0654..f381b133 100644 --- a/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java +++ b/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java @@ -15,6 +15,7 @@ import im.toduck.domain.badge.persistence.entity.Badge; import im.toduck.domain.badge.persistence.entity.UserBadge; import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.exception.CommonException; @Transactional class BadgeServiceTest extends ServiceTest { @@ -51,7 +52,52 @@ void grantBadge_duplicate() { // when & then assertThatThrownBy(() -> badgeService.grantBadge(user, badge.getCode())) - .isInstanceOf(im.toduck.global.exception.CommonException.class) + .isInstanceOf(CommonException.class) .hasMessageContaining(ALREADY_ACQUIRED_BADGE.getMessage()); } + + @Test + @DisplayName("보유한 배지로 대표 뱃지를 설정할 수 있다.") + void setRepresentativeBadge_success() { + // given + UserBadge userBadge = badgeService.grantBadge(user, badge.getCode()); + + // when + badgeService.setRepresentativeBadge(user, badge.getId()); + + // then + assertThat(userBadge.isRepresentative()).isTrue(); + } + + @Test + @DisplayName("기존 대표 배지가 있을 경우, 새로운 배지로 대표가 변경되고 기존 배지는 해제된다") + void setRepresentativeBadge_change() { + // given + // 1. 첫 번째 뱃지 획득 및 대표 설정 + UserBadge oldBadge = UserBadge.builder().user(user).badge(badge).build(); + testFixtureBuilder.buildUserBadge(oldBadge, true); + + // 2. 두 번째 뱃지 생성 및 획득 + Badge newBadge = testFixtureBuilder.buildBadge(PERFECTIONIST_BADGE()); + UserBadge newUserBadge = badgeService.grantBadge(user, newBadge.getCode()); + + // when + badgeService.setRepresentativeBadge(user, newBadge.getId()); + + // then + assertThat(oldBadge.isRepresentative()).isFalse(); + assertThat(newUserBadge.isRepresentative()).isTrue(); + } + + @Test + @DisplayName("보유하지 않은 배지를 대표로 설정하려 하면 NOT_OWNED_BADGE 예외가 발생한다") + void setRepresentativeBadge_notOwned() { + // given + Badge notOwnedBadge = testFixtureBuilder.buildBadge(PERFECTIONIST_BADGE()); + + // when & then + assertThatThrownBy(() -> badgeService.setRepresentativeBadge(user, notOwnedBadge.getId())) + .isInstanceOf(CommonException.class) + .hasMessageContaining(NOT_OWNED_BADGE.getMessage()); + } } diff --git a/src/test/java/im/toduck/fixtures/badge/BadgeFixtures.java b/src/test/java/im/toduck/fixtures/badge/BadgeFixtures.java index 58ac9087..b92ac09b 100644 --- a/src/test/java/im/toduck/fixtures/badge/BadgeFixtures.java +++ b/src/test/java/im/toduck/fixtures/badge/BadgeFixtures.java @@ -12,4 +12,13 @@ public static Badge BABY_DUCK_BADGE() { .imageUrl("https://example.com/images/badges/baby-duck.png") .build(); } + + public static Badge PERFECTIONIST_BADGE() { + return Badge.builder() + .code(BadgeCode.PERFECTIONIST) + .name("완벽주의") + .description("루틴 개수가 10개 이상입니다.") + .imageUrl("https://example.com/images/badges/perfectionist.png") + .build(); + } } From 402ac873a78da373e217d551f4d60caff1b8298d Mon Sep 17 00:00:00 2001 From: Junad-Park Date: Sat, 17 Jan 2026 18:31:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=B0=B0=EC=A7=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=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 --- .../badge/common/mapper/UserBadgeMapper.java | 19 ++++++ .../badge/domain/service/BadgeService.java | 22 +++++++ .../badge/domain/usecase/BadgeUseCase.java | 17 ++++- .../badge/presentation/api/BadgeApi.java | 15 ++++- .../controller/BadgeController.java | 13 +++- .../dto/response/BadgeListResponse.java | 29 +++++++++ .../dto/response/BadgeResponse.java | 2 +- .../im/toduck/builder/TestFixtureBuilder.java | 10 +-- .../domain/service/BadgeServiceTest.java | 62 ++++++++++++++----- .../domain/usecase/BadgeUseCaseTest.java | 2 +- 10 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java rename src/main/java/im/toduck/domain/badge/{common => presentation}/dto/response/BadgeResponse.java (93%) diff --git a/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java b/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java index 133615cb..e0a19dbc 100644 --- a/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java +++ b/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java @@ -1,7 +1,11 @@ package im.toduck.domain.badge.common.mapper; +import java.util.List; + import im.toduck.domain.badge.persistence.entity.Badge; import im.toduck.domain.badge.persistence.entity.UserBadge; +import im.toduck.domain.badge.presentation.dto.response.BadgeListResponse; +import im.toduck.domain.badge.presentation.dto.response.BadgeResponse; import im.toduck.domain.user.persistence.entity.User; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -15,4 +19,19 @@ public static UserBadge toUserBadge(final User user, final Badge badge) { .badge(badge) .build(); } + + public static BadgeListResponse toBadgeListResponse(long totalCount, List userBadges) { + Long representativeBadgeId = userBadges.stream() + .filter(UserBadge::isRepresentative) + .findFirst() + .map(userBadge -> userBadge.getBadge().getId()) + .orElse(null); + + List ownedBadges = userBadges.stream() + .map(UserBadge::getBadge) + .map(BadgeResponse::from) + .toList(); + + return BadgeListResponse.of(totalCount, representativeBadgeId, ownedBadges); + } } diff --git a/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java b/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java index dc95f66d..ebdc9126 100644 --- a/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java +++ b/src/main/java/im/toduck/domain/badge/domain/service/BadgeService.java @@ -11,6 +11,7 @@ import im.toduck.domain.badge.persistence.entity.UserBadge; import im.toduck.domain.badge.persistence.repository.BadgeRepository; import im.toduck.domain.badge.persistence.repository.UserBadgeRepository; +import im.toduck.domain.badge.presentation.dto.response.BadgeListResponse; import im.toduck.domain.user.persistence.entity.User; import im.toduck.global.exception.CommonException; import im.toduck.global.exception.ExceptionCode; @@ -61,6 +62,27 @@ public List getUnseenBadges(final User user) { return userBadgeRepository.findAllByUserAndIsSeenFalse(user); } + /** + * 사용자의 뱃지 목록 정보를 조회합니다. + * + * @param user 조회할 사용자 + * @return 뱃지 목록 정보 (전체 개수, 대표 뱃지 ID, 보유 뱃지 목록) + */ + @Transactional(readOnly = true) + public BadgeListResponse getMyBadgeList(final User user) { + long totalCount = badgeRepository.count(); + List userBadges = userBadgeRepository.findAllByUser(user); + + return UserBadgeMapper.toBadgeListResponse(totalCount, userBadges); + } + + /** + * 사용자의 대표 뱃지를 설정합니다. + * 기존 대표 뱃지가 있다면 해제하고, 새로운 뱃지를 대표로 설정합니다. + * + * @param user 뱃지를 설정할 사용자 + * @param badgeId 대표로 설정할 뱃지 ID (UserBadge PK가 아닌 Badge PK) + */ @Transactional public void setRepresentativeBadge(final User user, final Long badgeId) { Badge badge = badgeRepository.findById(badgeId) diff --git a/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java b/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java index 0d09afbc..36d220ec 100644 --- a/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java +++ b/src/main/java/im/toduck/domain/badge/domain/usecase/BadgeUseCase.java @@ -6,10 +6,11 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.annotation.Transactional; -import im.toduck.domain.badge.common.dto.response.BadgeResponse; import im.toduck.domain.badge.domain.service.BadgeService; import im.toduck.domain.badge.persistence.entity.BadgeCode; import im.toduck.domain.badge.persistence.entity.UserBadge; +import im.toduck.domain.badge.presentation.dto.response.BadgeListResponse; +import im.toduck.domain.badge.presentation.dto.response.BadgeResponse; import im.toduck.domain.notification.domain.data.BadgeAcquiredNotificationData; import im.toduck.domain.notification.domain.event.BadgeAcquiredNotificationEvent; import im.toduck.domain.user.domain.service.UserService; @@ -67,6 +68,20 @@ public List getNewBadges(final Long userId) { return responses; } + /** + * 사용자의 뱃지 목록 정보를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 뱃지 목록 정보 + */ + @Transactional(readOnly = true) + public BadgeListResponse getMyBadgeList(final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + return badgeService.getMyBadgeList(user); + } + /** * 사용자의 대표 뱃지를 설정합니다. * diff --git a/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java b/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java index 33a14d26..e51014c3 100644 --- a/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java +++ b/src/main/java/im/toduck/domain/badge/presentation/api/BadgeApi.java @@ -5,10 +5,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; -import im.toduck.domain.badge.common.dto.response.BadgeResponse; import im.toduck.domain.badge.presentation.dto.request.RepresentativeBadgeRequest; +import im.toduck.domain.badge.presentation.dto.response.BadgeListResponse; +import im.toduck.domain.badge.presentation.dto.response.BadgeResponse; import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; import im.toduck.global.annotation.swagger.ApiResponseExplanations; import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; @@ -32,6 +34,17 @@ ResponseEntity>> getNewBadges( @AuthenticationPrincipal CustomUserDetails userDetails ); + @Operation(summary = "내 뱃지 목록 조회", description = "보유한 뱃지 목록과 대표 뱃지 설정 정보를 조회합니다.") + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "내 뱃지 목록 조회 성공" + ) + ) + @GetMapping + ResponseEntity> getMyBadgeList( + @AuthenticationPrincipal CustomUserDetails userDetails + ); + @Operation(summary = "대표 뱃지 설정", description = "사용자의 대표 뱃지를 설정하거나 변경합니다.") @ApiResponseExplanations( success = @ApiSuccessResponseExplanation( diff --git a/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java b/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java index e492b972..c3cd3fec 100644 --- a/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java +++ b/src/main/java/im/toduck/domain/badge/presentation/controller/BadgeController.java @@ -12,10 +12,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import im.toduck.domain.badge.common.dto.response.BadgeResponse; import im.toduck.domain.badge.domain.usecase.BadgeUseCase; import im.toduck.domain.badge.presentation.api.BadgeApi; import im.toduck.domain.badge.presentation.dto.request.RepresentativeBadgeRequest; +import im.toduck.domain.badge.presentation.dto.response.BadgeListResponse; +import im.toduck.domain.badge.presentation.dto.response.BadgeResponse; import im.toduck.global.presentation.ApiResponse; import im.toduck.global.security.authentication.CustomUserDetails; import jakarta.validation.Valid; @@ -38,6 +39,16 @@ public ResponseEntity>> getNewBadges( return ResponseEntity.ok(ApiResponse.createSuccess(responses)); } + @Override + @GetMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getMyBadgeList( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + BadgeListResponse response = badgeUseCase.getMyBadgeList(userDetails.getUserId()); + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + @Override @PatchMapping("/representative") @PreAuthorize("isAuthenticated()") diff --git a/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java new file mode 100644 index 00000000..a253448f --- /dev/null +++ b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java @@ -0,0 +1,29 @@ +package im.toduck.domain.badge.presentation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "내 뱃지 목록 조회 응답") +public class BadgeListResponse { + @Schema(description = "전체 뱃지 개수") + private long totalCount; + + @Schema(description = "대표 뱃지 ID (없으면 null)") + private Long representativeBadgeId; + + @Schema(description = "보유한 뱃지 리스트") + private List ownedBadges; + + public static BadgeListResponse of(long totalCount, Long representativeBadgeId, List ownedBadges) { + return BadgeListResponse.builder() + .totalCount(totalCount) + .representativeBadgeId(representativeBadgeId) + .ownedBadges(ownedBadges) + .build(); + } +} diff --git a/src/main/java/im/toduck/domain/badge/common/dto/response/BadgeResponse.java b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeResponse.java similarity index 93% rename from src/main/java/im/toduck/domain/badge/common/dto/response/BadgeResponse.java rename to src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeResponse.java index 8cd43688..db008b5a 100644 --- a/src/main/java/im/toduck/domain/badge/common/dto/response/BadgeResponse.java +++ b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeResponse.java @@ -1,4 +1,4 @@ -package im.toduck.domain.badge.common.dto.response; +package im.toduck.domain.badge.presentation.dto.response; import im.toduck.domain.badge.persistence.entity.Badge; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/test/java/im/toduck/builder/TestFixtureBuilder.java b/src/test/java/im/toduck/builder/TestFixtureBuilder.java index 812d0835..147bac3c 100644 --- a/src/test/java/im/toduck/builder/TestFixtureBuilder.java +++ b/src/test/java/im/toduck/builder/TestFixtureBuilder.java @@ -140,11 +140,11 @@ public Badge buildBadge(final Badge badge) { return bs.badgeRepository().save(badge); } - public UserBadge buildUserBadge(final UserBadge userBadge) { - return bs.userBadgeRepository().save(userBadge); - } - - public UserBadge buildUserBadge(final UserBadge userBadge, boolean isRepresentative) { + public UserBadge buildUserBadge(final User user, final Badge badge, boolean isRepresentative) { + UserBadge userBadge = UserBadge.builder() + .user(user) + .badge(badge) + .build(); ReflectionTestUtils.setField(userBadge, "isRepresentative", isRepresentative); return bs.userBadgeRepository().save(userBadge); } diff --git a/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java b/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java index f381b133..55718ea1 100644 --- a/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java +++ b/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java @@ -14,6 +14,7 @@ import im.toduck.ServiceTest; import im.toduck.domain.badge.persistence.entity.Badge; import im.toduck.domain.badge.persistence.entity.UserBadge; +import im.toduck.domain.badge.presentation.dto.response.BadgeListResponse; import im.toduck.domain.user.persistence.entity.User; import im.toduck.global.exception.CommonException; @@ -23,35 +24,37 @@ class BadgeServiceTest extends ServiceTest { @Autowired BadgeService badgeService; - private User user; - private Badge badge; + private User USER; @BeforeEach void setUp() { - user = testFixtureBuilder.buildUser(GENERAL_USER()); - badge = testFixtureBuilder.buildBadge(BABY_DUCK_BADGE()); + USER = testFixtureBuilder.buildUser(GENERAL_USER()); } @Test @DisplayName("배지를 처음 획득하면 UserBadge를 반환한다") void grantBadge_new() { + // given + Badge BABY_DUCK_BADGE = testFixtureBuilder.buildBadge(BABY_DUCK_BADGE()); + // when - UserBadge result = badgeService.grantBadge(user, badge.getCode()); + UserBadge result = badgeService.grantBadge(USER, BABY_DUCK_BADGE.getCode()); // then assertThat(result).isNotNull(); - assertThat(result.getBadge()).isEqualTo(badge); - assertThat(result.getUser()).isEqualTo(user); + assertThat(result.getBadge()).isEqualTo(BABY_DUCK_BADGE); + assertThat(result.getUser()).isEqualTo(USER); } @Test @DisplayName("이미 보유한 배지를 획득하려 하면 ALREADY_ACQUIRED_BADGE 예외가 발생한다") void grantBadge_duplicate() { // given - badgeService.grantBadge(user, badge.getCode()); + Badge BABY_DUCK_BADGE = testFixtureBuilder.buildBadge(BABY_DUCK_BADGE()); + badgeService.grantBadge(USER, BABY_DUCK_BADGE.getCode()); // when & then - assertThatThrownBy(() -> badgeService.grantBadge(user, badge.getCode())) + assertThatThrownBy(() -> badgeService.grantBadge(USER, BABY_DUCK_BADGE.getCode())) .isInstanceOf(CommonException.class) .hasMessageContaining(ALREADY_ACQUIRED_BADGE.getMessage()); } @@ -60,10 +63,11 @@ void grantBadge_duplicate() { @DisplayName("보유한 배지로 대표 뱃지를 설정할 수 있다.") void setRepresentativeBadge_success() { // given - UserBadge userBadge = badgeService.grantBadge(user, badge.getCode()); + Badge BABY_DUCK_BADGE = testFixtureBuilder.buildBadge(BABY_DUCK_BADGE()); + UserBadge userBadge = badgeService.grantBadge(USER, BABY_DUCK_BADGE.getCode()); // when - badgeService.setRepresentativeBadge(user, badge.getId()); + badgeService.setRepresentativeBadge(USER, BABY_DUCK_BADGE.getId()); // then assertThat(userBadge.isRepresentative()).isTrue(); @@ -73,19 +77,19 @@ void setRepresentativeBadge_success() { @DisplayName("기존 대표 배지가 있을 경우, 새로운 배지로 대표가 변경되고 기존 배지는 해제된다") void setRepresentativeBadge_change() { // given + Badge oldBadge = testFixtureBuilder.buildBadge(BABY_DUCK_BADGE()); // 1. 첫 번째 뱃지 획득 및 대표 설정 - UserBadge oldBadge = UserBadge.builder().user(user).badge(badge).build(); - testFixtureBuilder.buildUserBadge(oldBadge, true); + UserBadge oldUserBadge = testFixtureBuilder.buildUserBadge(USER, oldBadge, true); // 2. 두 번째 뱃지 생성 및 획득 Badge newBadge = testFixtureBuilder.buildBadge(PERFECTIONIST_BADGE()); - UserBadge newUserBadge = badgeService.grantBadge(user, newBadge.getCode()); + UserBadge newUserBadge = testFixtureBuilder.buildUserBadge(USER, newBadge, false); // when - badgeService.setRepresentativeBadge(user, newBadge.getId()); + badgeService.setRepresentativeBadge(USER, newBadge.getId()); // then - assertThat(oldBadge.isRepresentative()).isFalse(); + assertThat(oldUserBadge.isRepresentative()).isFalse(); assertThat(newUserBadge.isRepresentative()).isTrue(); } @@ -96,8 +100,32 @@ void setRepresentativeBadge_notOwned() { Badge notOwnedBadge = testFixtureBuilder.buildBadge(PERFECTIONIST_BADGE()); // when & then - assertThatThrownBy(() -> badgeService.setRepresentativeBadge(user, notOwnedBadge.getId())) + assertThatThrownBy(() -> badgeService.setRepresentativeBadge(USER, notOwnedBadge.getId())) .isInstanceOf(CommonException.class) .hasMessageContaining(NOT_OWNED_BADGE.getMessage()); } + + @Test + @DisplayName("내 배지 목록을 조회하면 전체 개수, 대표 뱃지 ID, 보유 뱃지 리스트를 반환한다") + void getMyBadgeList() { + // given + // 1. 뱃지 2개 생성 (전체 뱃지 개수: 2) + Badge firstBadge = testFixtureBuilder.buildBadge(BABY_DUCK_BADGE()); + Badge secondBadge = testFixtureBuilder.buildBadge(PERFECTIONIST_BADGE()); + + // 2. 사용자에게 첫 번째 뱃지 지급 및 대표 설정 + UserBadge userBadge1 = testFixtureBuilder.buildUserBadge(USER, firstBadge, true); + // 3. 사용자에게 두 번째 뱃지 지급 (대표 아님) + UserBadge userBadge2 = testFixtureBuilder.buildUserBadge(USER, secondBadge, false); + + // when + BadgeListResponse response = badgeService.getMyBadgeList(USER); + + // then + assertThat(response.getTotalCount()).isEqualTo(2); // 전체 뱃지 종류 수 + assertThat(response.getRepresentativeBadgeId()).isEqualTo(firstBadge.getId()); + assertThat(response.getOwnedBadges()).hasSize(2) + .extracting("id") + .containsExactlyInAnyOrder(firstBadge.getId(), secondBadge.getId()); + } } diff --git a/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java b/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java index e54881e1..13e77743 100644 --- a/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java +++ b/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java @@ -13,7 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import im.toduck.ServiceTest; -import im.toduck.domain.badge.common.dto.response.BadgeResponse; +import im.toduck.domain.badge.presentation.dto.response.BadgeResponse; import im.toduck.domain.badge.domain.service.BadgeService; import im.toduck.domain.badge.persistence.entity.Badge; import im.toduck.domain.badge.persistence.entity.UserBadge; From 6c3d60789ad65d43afaae5ba6806d95ee3b76c77 Mon Sep 17 00:00:00 2001 From: Junad-Park Date: Sat, 17 Jan 2026 18:51:17 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20badge=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=91=EB=8B=B5=20DTO=EB=A5=BC=20record=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../badge/common/mapper/UserBadgeMapper.java | 2 +- .../dto/response/BadgeListResponse.java | 18 ++++++++++-------- .../dto/response/BadgeResponse.java | 18 ++++++++---------- .../badge/domain/service/BadgeServiceTest.java | 6 +++--- .../badge/domain/usecase/BadgeUseCaseTest.java | 4 ++-- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java b/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java index e0a19dbc..01eebbec 100644 --- a/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java +++ b/src/main/java/im/toduck/domain/badge/common/mapper/UserBadgeMapper.java @@ -20,7 +20,7 @@ public static UserBadge toUserBadge(final User user, final Badge badge) { .build(); } - public static BadgeListResponse toBadgeListResponse(long totalCount, List userBadges) { + public static BadgeListResponse toBadgeListResponse(final long totalCount, final List userBadges) { Long representativeBadgeId = userBadges.stream() .filter(UserBadge::isRepresentative) .findFirst() diff --git a/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java index a253448f..e22ff6b8 100644 --- a/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java +++ b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeListResponse.java @@ -4,22 +4,24 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; -import lombok.Getter; -@Getter @Builder @Schema(description = "내 뱃지 목록 조회 응답") -public class BadgeListResponse { +public record BadgeListResponse( @Schema(description = "전체 뱃지 개수") - private long totalCount; + long totalCount, @Schema(description = "대표 뱃지 ID (없으면 null)") - private Long representativeBadgeId; + Long representativeBadgeId, @Schema(description = "보유한 뱃지 리스트") - private List ownedBadges; - - public static BadgeListResponse of(long totalCount, Long representativeBadgeId, List ownedBadges) { + List ownedBadges +) { + public static BadgeListResponse of( + final long totalCount, + final Long representativeBadgeId, + final List ownedBadges + ) { return BadgeListResponse.builder() .totalCount(totalCount) .representativeBadgeId(representativeBadgeId) diff --git a/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeResponse.java b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeResponse.java index db008b5a..5c6dd66c 100644 --- a/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeResponse.java +++ b/src/main/java/im/toduck/domain/badge/presentation/dto/response/BadgeResponse.java @@ -3,28 +3,26 @@ import im.toduck.domain.badge.persistence.entity.Badge; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; -import lombok.Getter; -@Getter @Builder @Schema(description = "뱃지 정보 응답") -public class BadgeResponse { +public record BadgeResponse( @Schema(description = "뱃지 ID") - private Long id; + Long id, @Schema(description = "뱃지 코드") - private String code; + String code, @Schema(description = "뱃지 이름") - private String name; + String name, @Schema(description = "뱃지 설명") - private String description; + String description, @Schema(description = "뱃지 이미지 URL") - private String imageUrl; - - public static BadgeResponse from(Badge badge) { + String imageUrl +) { + public static BadgeResponse from(final Badge badge) { return BadgeResponse.builder() .id(badge.getId()) .code(badge.getCode().name()) diff --git a/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java b/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java index 55718ea1..a6385b13 100644 --- a/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java +++ b/src/test/java/im/toduck/domain/badge/domain/service/BadgeServiceTest.java @@ -122,9 +122,9 @@ void getMyBadgeList() { BadgeListResponse response = badgeService.getMyBadgeList(USER); // then - assertThat(response.getTotalCount()).isEqualTo(2); // 전체 뱃지 종류 수 - assertThat(response.getRepresentativeBadgeId()).isEqualTo(firstBadge.getId()); - assertThat(response.getOwnedBadges()).hasSize(2) + assertThat(response.totalCount()).isEqualTo(2); // 전체 뱃지 종류 수 + assertThat(response.representativeBadgeId()).isEqualTo(firstBadge.getId()); + assertThat(response.ownedBadges()).hasSize(2) .extracting("id") .containsExactlyInAnyOrder(firstBadge.getId(), secondBadge.getId()); } diff --git a/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java b/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java index 13e77743..2997bb3a 100644 --- a/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java +++ b/src/test/java/im/toduck/domain/badge/domain/usecase/BadgeUseCaseTest.java @@ -13,10 +13,10 @@ import org.springframework.transaction.annotation.Transactional; import im.toduck.ServiceTest; -import im.toduck.domain.badge.presentation.dto.response.BadgeResponse; import im.toduck.domain.badge.domain.service.BadgeService; import im.toduck.domain.badge.persistence.entity.Badge; import im.toduck.domain.badge.persistence.entity.UserBadge; +import im.toduck.domain.badge.presentation.dto.response.BadgeResponse; import im.toduck.domain.user.persistence.entity.User; @Transactional @@ -48,7 +48,7 @@ void getNewBadges_success() { // then assertThat(newBadges).hasSize(1); - assertThat(newBadges.get(0).getCode()).isEqualTo(badge.getCode().name()); + assertThat(newBadges.get(0).code()).isEqualTo(badge.getCode().name()); List unseenBadges = badgeService.getUnseenBadges(user); assertThat(unseenBadges).isEmpty();