diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/UnlockedProfileImagePersistenceAdapter.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/UnlockedProfileImagePersistenceAdapter.java new file mode 100644 index 00000000..5ed97118 --- /dev/null +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/UnlockedProfileImagePersistenceAdapter.java @@ -0,0 +1,59 @@ +package com.spoony.spoony_server.adapter.out.persistence.user; + +import com.spoony.spoony_server.adapter.out.persistence.user.db.UnlockedProfileImageEntity; +import com.spoony.spoony_server.adapter.out.persistence.user.db.UnlockedProfileImageRepository; +import com.spoony.spoony_server.adapter.out.persistence.user.db.UserEntity; +import com.spoony.spoony_server.adapter.out.persistence.user.db.UserRepository; +import com.spoony.spoony_server.application.port.out.user.UnlockedProfileImagePort; +import com.spoony.spoony_server.global.exception.BusinessException; +import com.spoony.spoony_server.global.message.business.UserErrorMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class UnlockedProfileImagePersistenceAdapter implements UnlockedProfileImagePort { + + private final UnlockedProfileImageRepository unlockedProfileImageRepository; + private final UserRepository userRepository; + + @Override + public Set findUnlockedLevelsByUserId(Long userId) { + List levels = unlockedProfileImageRepository.findProfileLevelsByUserId(userId); + return new HashSet<>(levels); + } + + @Override + @Transactional + public void saveUnlockedLevel(Long userId, Integer profileLevel) { + // 중복 저장 방지 + if (isLevelUnlocked(userId, profileLevel)) { + log.debug("Profile level {} already unlocked for user {}", profileLevel, userId); + return; + } + + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(UserErrorMessage.USER_NOT_FOUND)); + + UnlockedProfileImageEntity entity = UnlockedProfileImageEntity.builder() + .user(user) + .profileLevel(profileLevel) + .build(); + + unlockedProfileImageRepository.save(entity); + log.info("Profile level {} unlocked for user {}", profileLevel, userId); + } + + @Override + public boolean isLevelUnlocked(Long userId, Integer profileLevel) { + return unlockedProfileImageRepository + .existsByUser_UserIdAndProfileLevel(userId, profileLevel); + } +} diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageEntity.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageEntity.java new file mode 100644 index 00000000..aa19e154 --- /dev/null +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageEntity.java @@ -0,0 +1,46 @@ +package com.spoony.spoony_server.adapter.out.persistence.user.db; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "unlocked_profile_image", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_profile_level", + columnNames = {"user_id", "profile_level"} + ) +) +public class UnlockedProfileImageEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(name = "profile_level", nullable = false) + private Integer profileLevel; + + @CreatedDate + @Column(name = "unlocked_at", nullable = false, updatable = false) + private LocalDateTime unlockedAt; + + @Builder + public UnlockedProfileImageEntity(UserEntity user, Integer profileLevel) { + this.user = user; + this.profileLevel = profileLevel; + } +} diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageRepository.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageRepository.java new file mode 100644 index 00000000..caccca5d --- /dev/null +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageRepository.java @@ -0,0 +1,28 @@ +package com.spoony.spoony_server.adapter.out.persistence.user.db; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UnlockedProfileImageRepository extends JpaRepository { + + /** + * 사용자의 모든 잠금해제된 프로필 조회 + */ + List findByUser_UserId(Long userId); + + /** + * 특정 레벨이 잠금해제되었는지 확인 + */ + boolean existsByUser_UserIdAndProfileLevel(Long userId, Integer profileLevel); + + /** + * 사용자의 잠금해제된 프로필 레벨 목록 조회 (레벨 번호만) + */ + @Query("SELECT u.profileLevel FROM UnlockedProfileImageEntity u WHERE u.user.userId = :userId") + List findProfileLevelsByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/UnlockedProfileImageMapper.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/UnlockedProfileImageMapper.java new file mode 100644 index 00000000..8c830133 --- /dev/null +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/UnlockedProfileImageMapper.java @@ -0,0 +1,20 @@ +package com.spoony.spoony_server.adapter.out.persistence.user.mapper; + +import com.spoony.spoony_server.adapter.out.persistence.user.db.UnlockedProfileImageEntity; +import com.spoony.spoony_server.domain.user.UnlockedProfileImage; + +public class UnlockedProfileImageMapper { + + public static UnlockedProfileImage toDomain(UnlockedProfileImageEntity entity) { + if (entity == null) { + return null; + } + + return new UnlockedProfileImage( + entity.getId(), + UserMapper.toDomain(entity.getUser()), + entity.getProfileLevel(), + entity.getUnlockedAt() + ); + } +} diff --git a/src/main/java/com/spoony/spoony_server/application/port/out/user/UnlockedProfileImagePort.java b/src/main/java/com/spoony/spoony_server/application/port/out/user/UnlockedProfileImagePort.java new file mode 100644 index 00000000..a0c89305 --- /dev/null +++ b/src/main/java/com/spoony/spoony_server/application/port/out/user/UnlockedProfileImagePort.java @@ -0,0 +1,28 @@ +package com.spoony.spoony_server.application.port.out.user; + +import java.util.Set; + +public interface UnlockedProfileImagePort { + + /** + * 사용자의 잠금해제된 프로필 레벨 조회 + * @param userId 사용자 ID + * @return 잠금해제된 레벨 Set + */ + Set findUnlockedLevelsByUserId(Long userId); + + /** + * 새로운 프로필 레벨 잠금해제 저장 + * @param userId 사용자 ID + * @param profileLevel 프로필 레벨 + */ + void saveUnlockedLevel(Long userId, Integer profileLevel); + + /** + * 특정 레벨이 잠금해제되었는지 확인 + * @param userId 사용자 ID + * @param profileLevel 프로필 레벨 + * @return 잠금해제 여부 + */ + boolean isLevelUnlocked(Long userId, Integer profileLevel); +} diff --git a/src/main/java/com/spoony/spoony_server/application/service/user/ProfileImageService.java b/src/main/java/com/spoony/spoony_server/application/service/user/ProfileImageService.java index 5a42e559..00fbab71 100644 --- a/src/main/java/com/spoony/spoony_server/application/service/user/ProfileImageService.java +++ b/src/main/java/com/spoony/spoony_server/application/service/user/ProfileImageService.java @@ -1,42 +1,68 @@ package com.spoony.spoony_server.application.service.user; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.spoony.spoony_server.adapter.dto.user.response.ProfileImageListResponseDTO; import com.spoony.spoony_server.adapter.dto.user.response.ProfileImageResponseDTO; import com.spoony.spoony_server.application.port.command.user.UserGetCommand; import com.spoony.spoony_server.application.port.in.user.ProfileImageGetUseCase; import com.spoony.spoony_server.application.port.out.post.PostPort; +import com.spoony.spoony_server.application.port.out.user.UnlockedProfileImagePort; import com.spoony.spoony_server.domain.post.Post; import com.spoony.spoony_server.domain.user.ProfileImage; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; +import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor public class ProfileImageService implements ProfileImageGetUseCase { - private final PostPort postPort; - - @Override - public ProfileImageListResponseDTO getAvailableProfileImages(UserGetCommand command) { - List postList = postPort.findPostsByUserId(command.getUserId()); - - long totalZzimCount = postList - .stream() - .mapToLong(Post::getZzimCount) - .sum(); - - List unlockedImages = new ArrayList<>(); - - for (ProfileImage profileImage : ProfileImage.values()){ - boolean isUnlocked = totalZzimCount >= profileImage.getRequiredZzimCount(); - if (isUnlocked){ - unlockedImages.add(ProfileImageResponseDTO.of(profileImage,true)); - } else{ - unlockedImages.add(ProfileImageResponseDTO.of(profileImage,false)); - } - } - return ProfileImageListResponseDTO.of(unlockedImages); - } + + private final PostPort postPort; + private final UnlockedProfileImagePort unlockedProfileImagePort; + + @Override + @Transactional + public ProfileImageListResponseDTO getAvailableProfileImages(UserGetCommand command) { + Long userId = command.getUserId(); + + // 1. 현재 총 찜 개수 계산 + List postList = postPort.findPostsByUserId(userId); + long totalZzimCount = postList.stream() + .mapToLong(Post::getZzimCount) + .sum(); + + // 2. 이미 잠금해제된 레벨 조회 + Set unlockedLevels = unlockedProfileImagePort + .findUnlockedLevelsByUserId(userId); + + List result = new ArrayList<>(); + + // 3. 각 레벨별로 확인 및 새로운 잠금해제 처리 + for (ProfileImage profileImage : ProfileImage.values()) { + int level = profileImage.getImageLevel(); + boolean wasUnlocked = unlockedLevels.contains(level); + boolean canUnlock = totalZzimCount >= profileImage.getRequiredZzimCount(); + + // 새로 잠금해제 조건 달성 시 저장 + if (!wasUnlocked && canUnlock) { + unlockedProfileImagePort.saveUnlockedLevel(userId, level); + result.add(ProfileImageResponseDTO.of(profileImage, true)); + } + // 이미 잠금해제된 경우 (한번 해제되면 영구 해제) + else if (wasUnlocked) { + result.add(ProfileImageResponseDTO.of(profileImage, true)); + } + // 잠금 상태 + else { + result.add(ProfileImageResponseDTO.of(profileImage, false)); + } + } + + return ProfileImageListResponseDTO.of(result); + } } diff --git a/src/main/java/com/spoony/spoony_server/application/service/user/UserService.java b/src/main/java/com/spoony/spoony_server/application/service/user/UserService.java index 24c31c77..26fc87aa 100644 --- a/src/main/java/com/spoony/spoony_server/application/service/user/UserService.java +++ b/src/main/java/com/spoony/spoony_server/application/service/user/UserService.java @@ -4,12 +4,11 @@ import com.spoony.spoony_server.adapter.dto.user.response.*; import com.spoony.spoony_server.adapter.out.persistence.block.db.BlockStatus; import com.spoony.spoony_server.application.port.command.user.*; -import com.spoony.spoony_server.application.port.in.user.BlockCheckUseCase; -import com.spoony.spoony_server.application.port.in.user.BlockUserCreateUseCase; import com.spoony.spoony_server.application.port.in.user.*; import com.spoony.spoony_server.application.port.out.feed.FeedPort; -import com.spoony.spoony_server.application.port.out.user.BlockPort; import com.spoony.spoony_server.application.port.out.post.PostPort; +import com.spoony.spoony_server.application.port.out.user.BlockPort; +import com.spoony.spoony_server.application.port.out.user.UnlockedProfileImagePort; import com.spoony.spoony_server.application.port.out.user.UserPort; import com.spoony.spoony_server.domain.post.Post; import com.spoony.spoony_server.domain.user.Block; @@ -41,9 +40,11 @@ public class UserService implements private final PostPort postPort; private final BlockPort blockPort; private final FeedPort feedPort; + private final UnlockedProfileImagePort unlockedProfileImagePort; @Override - public UserResponseDTO getUserInfo(RelatedUserGetCommand relatedUserGetCommand, UserFollowCommand userFollowCommand) { + public UserResponseDTO getUserInfo(RelatedUserGetCommand relatedUserGetCommand, + UserFollowCommand userFollowCommand) { Long userId = relatedUserGetCommand.getUserId(); // 로그인 유저 Long targetUserId = relatedUserGetCommand.getTargetUserId(); // 프로필 조회 대상 @@ -120,7 +121,8 @@ public FollowListResponseDTO getFollowers(FollowGetCommand command) { List userDTOList = followers.stream() .map(Follow::getFollower) .filter(followerUser -> - !blockedUserIds.contains(followerUser.getUserId()) && !blockerUserIds.contains(followerUser.getUserId()) && !reportedUserIds.contains(followerUser.getUserId()) + !blockedUserIds.contains(followerUser.getUserId()) && !blockerUserIds.contains(followerUser.getUserId()) + && !reportedUserIds.contains(followerUser.getUserId()) ) .map(followerUser -> { boolean isFollowing = userPort.existsFollowRelation(command.getUserId(), followerUser.getUserId()); @@ -158,11 +160,13 @@ public FollowListResponseDTO getFollowings(FollowGetCommand command) { List userDTOList = followings.stream() .map(Follow::getFollowing) .filter(followingUser -> - !blockedUserIds.contains(followingUser.getUserId()) && !blockerUserIds.contains(followingUser.getUserId())&& !reportedUserIds.contains(followingUser.getUserId()) + !blockedUserIds.contains(followingUser.getUserId()) && !blockerUserIds.contains( + followingUser.getUserId()) && !reportedUserIds.contains(followingUser.getUserId()) ) .map(followingUser -> { boolean isFollowing = userPort.existsFollowRelation(command.getUserId(), followingUser.getUserId()); - String regionName = followingUser.getRegion() != null ? followingUser.getRegion().getRegionName() : null; + String regionName = + followingUser.getRegion() != null ? followingUser.getRegion().getRegionName() : null; return UserSimpleResponseDTO.of( command.getUserId(), @@ -182,7 +186,7 @@ public BlockListResponseDTO getBlockings(UserGetCommand command) { List blockIds = userPort.findBlockedByUserId(command.getUserId()); List userBlockResponseDTOList = blockIds.stream() .filter(block -> block.getStatus() == BlockStatus.BLOCKED) - .map(block ->{ + .map(block -> { User blockedUser = block.getBlocked(); boolean isBlocked = blockPort.existsBlockUserRelation(command.getUserId(), blockedUser.getUserId()); String regionName = blockedUser.getRegion() != null ? blockedUser.getRegion().getRegionName() : null; @@ -209,7 +213,7 @@ public void createFollow(UserFollowCommand command) { throw new BusinessException(UserErrorMessage.ALEADY_FOLLOW); } - Optional blockStatus = blockPort.getBlockRelationStatus(userId,targetUserId); + Optional blockStatus = blockPort.getBlockRelationStatus(userId, targetUserId); //어차피 아래 조건문에 도달하지조차 않을테지만(클라 뷰에서 막혀서) 그래도 일단 추가 if (blockStatus.isPresent() && blockStatus.get() == BlockStatus.BLOCKED) { @@ -217,11 +221,11 @@ public void createFollow(UserFollowCommand command) { } //언팔로우->팔로우인지 or 신규 팔로우인지에 따라 분기처리 - if (blockStatus.isPresent() && blockStatus.get() == BlockStatus.UNFOLLOWED){ //언팔로우->팔로우 - blockPort.deleteUserBlockRelation(userId,targetUserId,BlockStatus.UNFOLLOWED); //block 테이블에서 삭제 - userPort.saveFollowRelation(userId,targetUserId); + if (blockStatus.isPresent() && blockStatus.get() == BlockStatus.UNFOLLOWED) { //언팔로우->팔로우 + blockPort.deleteUserBlockRelation(userId, targetUserId, BlockStatus.UNFOLLOWED); //block 테이블에서 삭제 + userPort.saveFollowRelation(userId, targetUserId); - } else{ //신규 팔로우 + } else { //신규 팔로우 userPort.saveNewFollowRelation(userId, targetUserId); userPort.saveFollowRelation(userId, targetUserId); } @@ -254,7 +258,26 @@ public void deleteFollow(UserFollowCommand command) { @Override public void updateUserProfile(UserUpdateCommand command) { - userPort.updateUser(command.getUserId(), command.getUserName(), command.getRegionId(), command.getIntroduction(), command.getBirth(), command.getImageLevel()); + // 프로필 이미지 레벨 검증 + if (command.getImageLevel() != null) { + boolean isUnlocked = unlockedProfileImagePort.isLevelUnlocked( + command.getUserId(), + command.getImageLevel().intValue() + ); + + if (!isUnlocked) { + throw new BusinessException(UserErrorMessage.PROFILE_IMAGE_NOT_UNLOCKED); + } + } + + userPort.updateUser( + command.getUserId(), + command.getUserName(), + command.getRegionId(), + command.getIntroduction(), + command.getBirth(), + command.getImageLevel() + ); } @Override @@ -264,7 +287,7 @@ public UserSearchResponseListDTO searchUsersByQuery(UserGetCommand command, User List userList = userPort.findByUserNameContaining(searchCommand.getQuery()); List userSearchResultList = userList.stream() - .filter(user -> !blockedUserIds.contains(user.getUserId())&& !blockerUserIds.contains(user.getUserId())) + .filter(user -> !blockedUserIds.contains(user.getUserId()) && !blockerUserIds.contains(user.getUserId())) .map(user -> { String regionName = user.getRegion() != null ? user.getRegion().getRegionName() : null; boolean isFollowing = userPort.existsFollowRelation(command.getUserId(), user.getUserId()); @@ -298,7 +321,7 @@ public void createUserBlock(BlockUserCommand command) { blockPort.saveOrUpdateUserBlockRelation(userId, targetUserId, BlockStatus.BLOCKED); userPort.deleteFollowRelation(userId, targetUserId); userPort.deleteFollowRelation(targetUserId, userId); - userPort.removeZzimRelationsBetweenUsers(userId,targetUserId); + userPort.removeZzimRelationsBetweenUsers(userId, targetUserId); } @Override diff --git a/src/main/java/com/spoony/spoony_server/domain/user/UnlockedProfileImage.java b/src/main/java/com/spoony/spoony_server/domain/user/UnlockedProfileImage.java new file mode 100644 index 00000000..6b521cf4 --- /dev/null +++ b/src/main/java/com/spoony/spoony_server/domain/user/UnlockedProfileImage.java @@ -0,0 +1,15 @@ +package com.spoony.spoony_server.domain.user; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class UnlockedProfileImage { + private Long id; + private User user; + private Integer profileLevel; + private LocalDateTime unlockedAt; +} diff --git a/src/main/java/com/spoony/spoony_server/global/message/business/UserErrorMessage.java b/src/main/java/com/spoony/spoony_server/global/message/business/UserErrorMessage.java index 9dff87f7..a88022b1 100644 --- a/src/main/java/com/spoony/spoony_server/global/message/business/UserErrorMessage.java +++ b/src/main/java/com/spoony/spoony_server/global/message/business/UserErrorMessage.java @@ -9,7 +9,8 @@ public enum UserErrorMessage implements DefaultErrorMessage { ALEADY_FOLLOW(HttpStatus.FORBIDDEN,"이미 팔로우 관계가 존재합니다."), USER_BLOCKED(HttpStatus.FORBIDDEN,"차단된 유저입니다."), BLOCK_RELATION_NOT_FOUND(HttpStatus.FORBIDDEN,"차단 관계가 아닙니다"), - ALEADY_UNFOLLOWED(HttpStatus.FORBIDDEN,"이미 언팔로우한 사용자입니다."); + ALEADY_UNFOLLOWED(HttpStatus.FORBIDDEN,"이미 언팔로우한 사용자입니다."), + PROFILE_IMAGE_NOT_UNLOCKED(HttpStatus.FORBIDDEN, "잠금 해제되지 않은 프로필 이미지입니다."); private HttpStatus httpStatus; private String message;