From 02a1cfa1fac11d5bd54316c243ef37fafa577f67 Mon Sep 17 00:00:00 2001 From: heogeonho Date: Thu, 9 Oct 2025 20:28:34 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[feat]=20UnlockedProfileImage=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/UnlockedProfileImage.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/spoony/spoony_server/domain/user/UnlockedProfileImage.java 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; +} From fb78afc94c4c5be0da2dd14d0d5dd90e58b2ee09 Mon Sep 17 00:00:00 2001 From: heogeonho Date: Thu, 9 Oct 2025 20:30:20 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[feat]=20UnlockedProfileImagePort=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=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 --- .../out/user/UnlockedProfileImagePort.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/spoony/spoony_server/application/port/out/user/UnlockedProfileImagePort.java 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); +} From 64be03fa5a877cef5186de62e472e6e309e8d070 Mon Sep 17 00:00:00 2001 From: heogeonho Date: Thu, 9 Oct 2025 20:30:43 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[feat]=20UnlockedProfileImage=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=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 --- .../user/db/UnlockedProfileImageEntity.java | 46 +++++++++++++++++++ .../db/UnlockedProfileImageRepository.java | 28 +++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageEntity.java create mode 100644 src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UnlockedProfileImageRepository.java 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); +} From 3a74081f084af6b7538a5f475c16590d256fdede Mon Sep 17 00:00:00 2001 From: heogeonho Date: Thu, 9 Oct 2025 20:30:55 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[feat]=20UnlockedProfileImage=20Mapper=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 --- .../mapper/UnlockedProfileImageMapper.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/mapper/UnlockedProfileImageMapper.java 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() + ); + } +} From ca03b13392549a72ce78062e059957ddb074ad99 Mon Sep 17 00:00:00 2001 From: heogeonho Date: Thu, 9 Oct 2025 20:31:04 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[feat]=20UnlockedProfileImage=20Persistence?= =?UTF-8?q?Adapter=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nlockedProfileImagePersistenceAdapter.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/UnlockedProfileImagePersistenceAdapter.java 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); + } +} From 8d1254011218e204b41f6a8e68b601802c2b7771 Mon Sep 17 00:00:00 2001 From: heogeonho Date: Thu, 9 Oct 2025 20:31:29 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[feat]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=ED=95=B4=EC=A0=9C=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/message/business/UserErrorMessage.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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; From e9df3e3f3d6fa3388b4861f734ad8008ba33cce9 Mon Sep 17 00:00:00 2001 From: heogeonho Date: Thu, 9 Oct 2025 20:37:16 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[refactor]=20ProfileImageService=20?= =?UTF-8?q?=EC=98=81=EA=B5=AC=20=EC=9E=A0=EA=B8=88=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/user/ProfileImageService.java | 80 ++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) 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); + } } From 8213f3c9acf492e4bbe061b5f4d78a9f5a8a3d99 Mon Sep 17 00:00:00 2001 From: heogeonho Date: Fri, 10 Oct 2025 03:16:53 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[feat]=20UserService=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=ED=95=B4=EC=A0=9C=20=EA=B2=80=EC=A6=9D=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 --- .../persistence/post/db/PostRepository.java | 13 +- .../out/persistence/user/db/UserEntity.java | 9 +- .../zzim/ZzimPersistenceAdapter.java | 47 +--- .../zzim/db/ZzimPostRepository.java | 20 +- .../application/service/user/UserService.java | 55 ++-- .../spoony/spoony_server/ZzimCountTest.java | 244 +++++++++++++++++- 6 files changed, 319 insertions(+), 69 deletions(-) diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/db/PostRepository.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/db/PostRepository.java index 9c3c56de..82b52476 100644 --- a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/db/PostRepository.java +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/post/db/PostRepository.java @@ -17,16 +17,15 @@ public interface PostRepository extends JpaRepository , JpaSpe Long countByUser_UserId(@Param("userId") Long userId, @Param("reportedPostIds") List reportedPostIds); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select p from PostEntity p where p.postId = :postId") - Optional findByIdForUpdate(@Param("postId") Long postId); + @Query(value = "SELECT 1 FROM post WHERE post_id = :postId FOR UPDATE", nativeQuery = true) + void lockPostForUpdate(@Param("postId") Long postId); - @Modifying - @Query("update PostEntity p set p.zzimCount = coalesce(p.zzimCount, 0) + 1 where p.postId = :postId") + @Modifying(clearAutomatically = true) + @Query("update PostEntity p set p.zzimCount = coalesce(p.zzimCount,0) + 1 where p.postId = :postId") void incrementZzimCount(@Param("postId") Long postId); - @Modifying - @Query("update PostEntity p set p.zzimCount = case when coalesce(p.zzimCount, 0) > 0 then p.zzimCount - 1 else 0 end where p.postId = :postId") + @Modifying(clearAutomatically = true) + @Query("update PostEntity p set p.zzimCount = case when coalesce(p.zzimCount,0)>0 then p.zzimCount-1 else 0 end where p.postId = :postId") void decrementZzimCount(@Param("postId") Long postId); //Long countByUser_UserId(Long userId); diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UserEntity.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UserEntity.java index 8af1e507..1b2acfe4 100644 --- a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UserEntity.java +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/user/db/UserEntity.java @@ -30,7 +30,14 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) -@Table(name = "user") +@Table(name = "user", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_user_platform", + columnNames = {"platform", "platform_id"} + ) + } +) public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/ZzimPersistenceAdapter.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/ZzimPersistenceAdapter.java index 619b8052..e20ea385 100644 --- a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/ZzimPersistenceAdapter.java +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/ZzimPersistenceAdapter.java @@ -18,14 +18,8 @@ import com.spoony.spoony_server.global.message.business.PostErrorMessage; import com.spoony.spoony_server.global.message.business.UserErrorMessage; import lombok.RequiredArgsConstructor; -import org.springframework.dao.CannotAcquireLockException; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.dao.DeadlockLoserDataAccessException; -import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @@ -68,15 +62,6 @@ public List findZzimPostsByUserIdSortedByCreatedAtDesc(Long userId) { .toList(); } - @Retryable( - retryFor = { - DeadlockLoserDataAccessException.class, - CannotAcquireLockException.class, - PessimisticLockingFailureException.class - }, - maxAttempts = 5, - backoff = @Backoff(delay = 5, maxDelay = 50, multiplier = 1.6, random = true) - ) @Transactional(isolation = Isolation.READ_COMMITTED) @Override public void saveZzimPost(User user, Post post) { @@ -84,19 +69,15 @@ public void saveZzimPost(User user, Post post) { .orElseThrow(() -> new BusinessException(UserErrorMessage.USER_NOT_FOUND)); UserEntity userEntity_author = userRepository.findById(post.getUser().getUserId()) .orElseThrow(() -> new BusinessException(UserErrorMessage.USER_NOT_FOUND)); - PostEntity postEntity = postRepository.findByIdForUpdate(post.getPostId()) + PostEntity postEntity = postRepository.findById(post.getPostId()) .orElseThrow(() -> new BusinessException(PostErrorMessage.POST_NOT_FOUND)); - ZzimPostEntity zzimPostEntity = ZzimPostEntity.builder() - .user(userEntity) - .author(userEntity_author) - .post(postEntity) - .build(); + postRepository.lockPostForUpdate(postEntity.getPostId()); - try { - zzimPostRepository.saveAndFlush(zzimPostEntity); - postRepository.incrementZzimCount(post.getPostId()); - } catch (DataIntegrityViolationException e) { + int inserted = zzimPostRepository.insertIfAbsent(userEntity.getUserId(), postEntity.getPostId(), userEntity_author.getUserId()); + if (inserted == 1) { + postRepository.incrementZzimCount(postEntity.getPostId()); + } else { throw new BusinessException(PostErrorMessage.ALREADY_ZZIM); } } @@ -117,24 +98,16 @@ public List findPhotoListById(Long postId) { .toList(); } - @Retryable( - retryFor = { - DeadlockLoserDataAccessException.class, - CannotAcquireLockException.class, - PessimisticLockingFailureException.class - }, - maxAttempts = 5, - backoff = @Backoff(delay = 5, maxDelay = 50, multiplier = 1.6, random = true) - ) @Transactional(isolation = Isolation.READ_COMMITTED) @Override public void deleteByUserAndPost(User user, Post post) { - PostEntity postEntity = postRepository.findByIdForUpdate(post.getPostId()) + PostEntity postEntity = postRepository.findById(post.getPostId()) .orElseThrow(() -> new BusinessException(PostErrorMessage.POST_NOT_FOUND)); - int deleted = zzimPostRepository.deleteByUser_UserIdAndPost_PostId(user.getUserId(), postEntity.getPostId()); + postRepository.lockPostForUpdate(postEntity.getPostId()); - if (deleted > 0) { + int deleted = zzimPostRepository.deleteOne(user.getUserId(), postEntity.getPostId()); + if (deleted == 1) { postRepository.decrementZzimCount(postEntity.getPostId()); } } diff --git a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/db/ZzimPostRepository.java b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/db/ZzimPostRepository.java index ac96fec9..da670efb 100644 --- a/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/db/ZzimPostRepository.java +++ b/src/main/java/com/spoony/spoony_server/adapter/out/persistence/zzim/db/ZzimPostRepository.java @@ -5,6 +5,9 @@ import com.spoony.spoony_server.domain.zzim.ZzimPost; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -12,17 +15,22 @@ @Repository public interface ZzimPostRepository extends JpaRepository, JpaSpecificationExecutor { - Long countByPost_PostId(Long postId); - Long countByUser_UserId(Long userId); boolean existsByUser_UserIdAndPost_PostId(Long userId, Long postId); - List findByUser_UserId(Long userId); - // 북마크 삭제에서 사용 - int deleteByUser_UserIdAndPost_PostId(Long userId, Long postId); + + @Modifying + @Query(value = "INSERT IGNORE INTO zzim_post (user_id, post_id, author_id, created_at) VALUES (:userId, :postId, :authorId, NOW())", nativeQuery = true) + int insertIfAbsent(@Param("userId") Long userId, + @Param("postId") Long postId, + @Param("authorId") Long authorId); + + @Modifying + @Query(value = "DELETE FROM zzim_post WHERE user_id = :userId AND post_id = :postId", nativeQuery = true) + int deleteOne(@Param("userId") Long userId, @Param("postId") Long postId); //유저 신고에서 사용 void deleteByUserAndAuthorAndPost(UserEntity user, UserEntity author, PostEntity post); - + long countByPost_PostId(long pid); } 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/test/java/com/spoony/spoony_server/ZzimCountTest.java b/src/test/java/com/spoony/spoony_server/ZzimCountTest.java index 7566b163..e0a93a85 100644 --- a/src/test/java/com/spoony/spoony_server/ZzimCountTest.java +++ b/src/test/java/com/spoony/spoony_server/ZzimCountTest.java @@ -5,15 +5,19 @@ import com.spoony.spoony_server.application.port.command.zzim.ZzimAddCommand; import com.spoony.spoony_server.application.port.command.zzim.ZzimDeleteCommand; import com.spoony.spoony_server.application.service.zzim.ZzimPostService; +import com.spoony.spoony_server.global.exception.BusinessException; +import com.spoony.spoony_server.global.message.business.PostErrorMessage; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.Arrays; import java.util.List; +import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; @@ -24,8 +28,8 @@ public class ZzimCountTest { @Autowired private PostRepository postRepository; @Autowired private ZzimPostRepository zzimPostRepository; - private final Long postId = 28L; - private final List userIds = Arrays.asList(33L, 34L, 35L); + private final Long postId = 1L; + private final List userIds = Arrays.asList(1L, 2L, 16L); @Test void 서로_다른_세명이_동시에_찜하면_정확히_3증가() throws Exception { @@ -94,4 +98,240 @@ public class ZzimCountTest { long after = postRepository.findById(postId).orElseThrow().getZzimCount(); assertThat(after).isEqualTo(before - userIds.size()); } + + @Test + void 한유저가_저장취소_10번_왕복_최종_카운트_0() throws InterruptedException { + final Long uid = 1L; + final Long pid = 4L; + + assertThat(zzimPostRepository.existsByUser_UserIdAndPost_PostId(uid, pid)).isFalse(); + assertThat(postRepository.findById(pid).orElseThrow().getZzimCount()).isZero(); + + int rounds = 20; // 클릭 횟수 (10쌍) + for (int i = 0; i < rounds; i++) { + if (i % 2 == 0) { + zzimService.addZzimPost(new ZzimAddCommand(uid, pid)); + } else { + zzimService.deleteZzim(new ZzimDeleteCommand(uid, pid)); + } + Thread.sleep(5); // 5ms 간격 (최대한 빠르게 누르는 상황을 테스트하기 위함) + } + + // 중간 검증: 최종 상태는 0 + boolean existsAfterToggle = zzimPostRepository.existsByUser_UserIdAndPost_PostId(uid, pid); + long countAfterToggle = postRepository.findById(pid).orElseThrow().getZzimCount(); + + assertThat(existsAfterToggle).isFalse(); + assertThat(countAfterToggle).isZero(); + + // 마지막으로 한 번 저장 + zzimService.addZzimPost(new ZzimAddCommand(uid, pid)); + + // 최종 검증: 레코드 존재, 카운트 +1 + boolean existsFinal = zzimPostRepository.existsByUser_UserIdAndPost_PostId(uid, pid); + long countFinal = postRepository.findById(pid).orElseThrow().getZzimCount(); + + assertThat(existsFinal).isTrue(); + assertThat(countFinal).isEqualTo(1); + } + + @Test + void 한유저가_같은글_따닥저장_최종_카운트_1() throws Exception { + final Long uid = 1L; + final Long pid = 4L; + + int threads = 2; + ExecutorService es = Executors.newFixedThreadPool(threads); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + AtomicInteger success = new AtomicInteger(); + AtomicInteger already = new AtomicInteger(); + AtomicInteger other = new AtomicInteger(); + + Runnable work = () -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + zzimService.addZzimPost(new ZzimAddCommand(uid, pid)); + success.incrementAndGet(); + } catch (BusinessException be) { + if (be.getErrorMessage() == PostErrorMessage.ALREADY_ZZIM) { + already.incrementAndGet(); + } else { + other.incrementAndGet(); + } + } catch (Exception e) { + other.incrementAndGet(); + } finally { + done.countDown(); + } + }; + + for (int i = 0; i < threads; i++) es.submit(work); + + ready.await(); + start.countDown(); + done.await(); + es.shutdown(); + + // 정확히 한 번만 성공, 한 번은 중복 예외, 그 외 예외 없음 + assertThat(success.get()).isEqualTo(1); + assertThat(already.get()).isEqualTo(1); + assertThat(other.get()).isEqualTo(0); + + // zzim_post 최종 1건 존재 + assertThat(zzimPostRepository.existsByUser_UserIdAndPost_PostId(uid, pid)).isTrue(); + + // post.zzimCount 최종 1 + long count = postRepository.findById(pid).orElseThrow().getZzimCount(); + assertThat(count).isEqualTo(1L); + } + + @Test + void 같은유저_같은포스트_따닥삭제시_최종값_0() throws Exception { + final long uid = 1L; + final long pid = 4L; + + int threads = 2; + ExecutorService es = Executors.newFixedThreadPool(threads); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + AtomicInteger otherErrors = new AtomicInteger(); + + Runnable work = () -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + zzimService.deleteZzim(new ZzimDeleteCommand(uid, pid)); + } catch (Exception e) { + otherErrors.incrementAndGet(); + } finally { + done.countDown(); + } + }; + + for (int i = 0; i < threads; i++) es.submit(work); + + ready.await(); + start.countDown(); + done.await(); + es.shutdown(); + + // 예기치 않은 예외 없어야 함 + assertThat(otherErrors.get()).isEqualTo(0); + + // 최종 상태 검증: 레코드 없음 + 카운트 0 + boolean existsAfter = zzimPostRepository.existsByUser_UserIdAndPost_PostId(uid, pid); + long countAfter = postRepository.findById(pid).orElseThrow().getZzimCount(); + + assertThat(existsAfter).isFalse(); + assertThat(countAfter).isZero(); + } + + @Test + void 두유저가_동시에_광클토글_최종정합성() throws Exception { + final long pid = 4L; + final long uidA = 1L; + final long uidB = 2L; + + int opsPerUser = 50; // 각 유저가 누르는 횟수 + ExecutorService es = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + + class Spammer implements Runnable { + private final long uid; + private final Random r = new Random(); + + Spammer(long uid) { this.uid = uid; } + + @Override public void run() { + ready.countDown(); + try { + start.await(); + } catch (InterruptedException ignored) {} + + for (int i = 0; i < opsPerUser; i++) { + boolean add = r.nextBoolean(); // 랜덤 토글 + try { + if (add) zzimService.addZzimPost(new ZzimAddCommand(uid, pid)); + else zzimService.deleteZzim(new ZzimDeleteCommand(uid, pid)); + } catch (Exception ignored) { + } + try { + Thread.sleep(r.nextInt(3)); + } catch (InterruptedException ignored) {} + } + done.countDown(); + } + } + + es.submit(new Spammer(uidA)); + es.submit(new Spammer(uidB)); + + ready.await(); + start.countDown(); + done.await(); + es.shutdown(); + + // 최종 검증 + long rowCount = zzimPostRepository.countByPost_PostId(pid); + long denorm = postRepository.findById(pid).orElseThrow().getZzimCount(); + + assertThat(denorm).isEqualTo(rowCount); + assertThat(denorm).isGreaterThanOrEqualTo(0); + } + + @Test + void 한유저_멀티스레드_랜덤토글_최종_행수_카운트_일치() throws Exception { + final long pid = 4L; + final long uid = 1L; + + final int threads = 10; + final int opsPerThread = 10; // 총 100회 + ExecutorService es = Executors.newFixedThreadPool(threads); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + class Burst implements Runnable { + private final Random r = new Random(); + @Override public void run() { + ready.countDown(); + try { + start.await(); + } catch (InterruptedException ignored) {} + for (int i = 0; i < opsPerThread; i++) { + boolean add = r.nextBoolean(); + try { + if (add) zzimService.addZzimPost(new ZzimAddCommand(uid, pid)); + else zzimService.deleteZzim(new ZzimDeleteCommand(uid, pid)); + } catch (Exception ignored) { + } + try { + Thread.sleep(r.nextInt(3)); + } catch (InterruptedException ignored) {} + } + done.countDown(); + } + } + + for (int i = 0; i < threads; i++) es.submit(new Burst()); + ready.await(); + start.countDown(); + done.await(); + es.shutdown(); + + // 실데이터 행수 vs 찜카운트 수 + long rowCount = zzimPostRepository.countByPost_PostId(pid); + long denorm = postRepository.findById(pid).orElseThrow().getZzimCount(); + + assertThat(denorm).isEqualTo(rowCount); + } + }