Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,4 +19,19 @@ public static UserBadge toUserBadge(final User user, final Badge badge) {
.badge(badge)
.build();
}

public static BadgeListResponse toBadgeListResponse(final long totalCount, final List<UserBadge> userBadges) {
Long representativeBadgeId = userBadges.stream()
.filter(UserBadge::isRepresentative)
.findFirst()
.map(userBadge -> userBadge.getBadge().getId())
.orElse(null);

List<BadgeResponse> ownedBadges = userBadges.stream()
.map(UserBadge::getBadge)
.map(BadgeResponse::from)
.toList();

return BadgeListResponse.of(totalCount, representativeBadgeId, ownedBadges);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,4 +61,39 @@ public UserBadge grantBadge(final User user, final BadgeCode badgeCode) {
public List<UserBadge> 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<UserBadge> 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)
.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);
}
Comment on lines +86 to +98

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p5: 현재는 "대표 배지를 다른 배지로 변경"만 가능한 것 같은데, 대표 배지를 해제(null로 설정)하는 케이스는 요구사항에 없는 걸까요? 만약 추후에 필요할 수 있다면 badgeId를 nullable로 받아서 해제도 지원하는 것도 고려해볼 수 있을 것 같아서 여쭤봅니다.

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,4 +67,32 @@ public List<BadgeResponse> 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);
}

/**
* 사용자의 대표 뱃지를 설정합니다.
*
* @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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,8 @@ private UserBadge(User user, Badge badge) {
public void markAsSeen() {
this.isSeen = true;
}

public void updateRepresentativeStatus(boolean isRepresentative) {
this.isRepresentative = isRepresentative;
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -14,4 +15,8 @@ public interface UserBadgeRepository extends JpaRepository<UserBadge, Long> {
List<UserBadge> findAllByUser(User user);

List<UserBadge> findAllByUserAndIsSeenFalse(User user);

Optional<UserBadge> findByUserAndIsRepresentativeTrue(User user);

Optional<UserBadge> findByUserAndBadge(User user, Badge badge);
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
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.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;
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 {
Expand All @@ -25,4 +33,30 @@ public interface BadgeApi {
ResponseEntity<ApiResponse<List<BadgeResponse>>> getNewBadges(
@AuthenticationPrincipal CustomUserDetails userDetails
);

@Operation(summary = "내 뱃지 목록 조회", description = "보유한 뱃지 목록과 대표 뱃지 설정 정보를 조회합니다.")
@ApiResponseExplanations(
success = @ApiSuccessResponseExplanation(
description = "내 뱃지 목록 조회 성공"
)
)
@GetMapping
ResponseEntity<ApiResponse<BadgeListResponse>> getMyBadgeList(
@AuthenticationPrincipal CustomUserDetails userDetails
);

@Operation(summary = "대표 뱃지 설정", description = "사용자의 대표 뱃지를 설정하거나 변경합니다.")
@ApiResponseExplanations(
success = @ApiSuccessResponseExplanation(
description = "대표 뱃지 설정 성공"
),
errors = {
@ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_BADGE),
@ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_OWNED_BADGE)
}
)
ResponseEntity<ApiResponse<Map<String, Object>>> updateRepresentativeBadge(
@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @RequestBody RepresentativeBadgeRequest request
);
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
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.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;
import lombok.RequiredArgsConstructor;

@RestController
Expand All @@ -32,4 +38,25 @@ public ResponseEntity<ApiResponse<List<BadgeResponse>>> getNewBadges(
List<BadgeResponse> responses = badgeUseCase.getNewBadges(userDetails.getUserId());
return ResponseEntity.ok(ApiResponse.createSuccess(responses));
}

@Override
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ApiResponse<BadgeListResponse>> getMyBadgeList(
@AuthenticationPrincipal CustomUserDetails userDetails
) {
BadgeListResponse response = badgeUseCase.getMyBadgeList(userDetails.getUserId());
return ResponseEntity.ok(ApiResponse.createSuccess(response));
}

@Override
@PatchMapping("/representative")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateRepresentativeBadge(
@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @RequestBody RepresentativeBadgeRequest request
) {
badgeUseCase.setRepresentativeBadge(userDetails.getUserId(), request.badgeId());
return ResponseEntity.ok(ApiResponse.createSuccessWithNoContent());
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package im.toduck.domain.badge.presentation.dto.response;

import java.util.List;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Builder
@Schema(description = "내 뱃지 목록 조회 응답")
public record BadgeListResponse(
@Schema(description = "전체 뱃지 개수")
long totalCount,

@Schema(description = "대표 뱃지 ID (없으면 null)")
Long representativeBadgeId,

@Schema(description = "보유한 뱃지 리스트")
List<BadgeResponse> ownedBadges
) {
public static BadgeListResponse of(
final long totalCount,
final Long representativeBadgeId,
final List<BadgeResponse> ownedBadges
) {
return BadgeListResponse.builder()
.totalCount(totalCount)
.representativeBadgeId(representativeBadgeId)
.ownedBadges(ownedBadges)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
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;
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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "보유하지 않은 뱃지입니다."),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3:
현재 FORBIDDEN(403)으로 되어 있는데, 403은 보통 인가(authorization) 실패를 의미하는 것 같아서요. 이 경우는 "해당 유저-배지 관계가 존재하지 않음"에 가까워서 NOT_FOUND(404)가 더 자연스럽지 않을까 싶은데, 혹시 403으로 설정하신 이유가 있을까요?


/* 431xx schedule */
NOT_FOUND_SCHEDULE_RECORD(HttpStatus.NOT_FOUND, 43101, "일정 기록을 찾을 수 없습니다.",
Expand Down
7 changes: 6 additions & 1 deletion src/test/java/im/toduck/builder/TestFixtureBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,12 @@ public Badge buildBadge(final Badge badge) {
return bs.badgeRepository().save(badge);
}

public UserBadge buildUserBadge(final UserBadge userBadge) {
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);
}
}
Loading