diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java index 4ec356b35..cf057234c 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java @@ -92,4 +92,9 @@ public void deleteSavedBook(Long userId, Long bookId) { public void deleteAllByIdInBatch(Set unusedBookIds) { bookJpaRepository.deleteAllByIdInBatch(unusedBookIds); } + + @Override + public void deleteAllSavedBookByUserId(Long userId) { + savedBookJpaRepository.deleteAllByUserId(userId); + } } diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java index 6038ecc7d..8bd1d2415 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java @@ -13,5 +13,9 @@ public interface SavedBookJpaRepository extends JpaRepository unusedBookIds); + + void deleteAllSavedBookByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java index 760283133..c0cafe272 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java @@ -10,9 +10,6 @@ import lombok.*; import org.hibernate.annotations.SQLDelete; -import java.util.ArrayList; -import java.util.List; - @Entity @Table(name = "comments") @Getter @@ -34,11 +31,15 @@ public class CommentJpaEntity extends BaseJpaEntity { @Column(name = "report_count", nullable = false) private int reportCount = 0; + /** + * -- SETTER -- + * 회원 탈퇴용 + */ + @Setter @Builder.Default @Column(name = "like_count", nullable = false) private int likeCount = 0; - //TODO 상속구조 해지하면서 postType만 가질지, postId + postType가질지 논의 필요 @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "post_id", nullable = false) private PostJpaEntity postJpaEntity; @@ -58,11 +59,6 @@ public class CommentJpaEntity extends BaseJpaEntity { @JoinColumn(name = "parent_id") private CommentJpaEntity parent; - // 삭제용 댓글 좋아요 양방향 매핑 관계 - @Builder.Default - @OneToMany(mappedBy = "commentJpaEntity", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List commentLikeList = new ArrayList<>(); - public CommentJpaEntity updateFrom(Comment comment) { this.reportCount = comment.getReportCount(); this.likeCount = comment.getLikeCount(); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java index eacfca1a3..5c31fc429 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java @@ -17,7 +17,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import java.util.Optional; +import java.util.*; +import java.util.stream.Collectors; import static konkuk.thip.common.exception.code.ErrorCode.*; @@ -57,17 +58,6 @@ public Long save(Comment comment) { ).getCommentId(); } - private PostJpaEntity findPostJpaEntity(PostType postType, Long postId) { - return switch (postType) { - case FEED -> feedJpaRepository.findByPostId(postId) - .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); - case RECORD -> recordJpaRepository.findByPostId(postId) - .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); - case VOTE -> voteJpaRepository.findByPostId(postId) - .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); - }; - } - @Override public Optional findById(Long id) { return commentJpaRepository.findByCommentId(id) @@ -97,4 +87,37 @@ public void softDeleteAllByPostId(Long postId) { commentJpaRepository.softDeleteAllByPostId(postId); } + @Override + public void deleteAllByUserId(Long userId) { + // 1. 탈퇴 유저가 작성한 댓글과 연관된 게시글을 JOIN FETCH로 함께 조회 + List commentsWithPosts = commentJpaRepository.findAllCommentsWithPostsByUserId(userId); + if (commentsWithPosts == null || commentsWithPosts.isEmpty()) { + return; //early return + } + + // 2. 삭제될 댓글이 어느 Post에 몇 개씩 붙어있는지 집계 (postId 기준 추천) + Map decMap = commentsWithPosts.stream() + .collect(Collectors.groupingBy(CommentJpaEntity::getPostJpaEntity, Collectors.counting())); + + // 3. 댓글 수를 집계만큼 한 번에 감소 + for (PostJpaEntity p : decMap.keySet()) { + long dec = decMap.getOrDefault(p, 0L); + p.setCommentCount(Math.max(0, p.getCommentCount() - (int) dec)); + } + + // 4. 탈퇴한 유저의 모든 댓글, 댓글의 좋아요 삭제 + commentLikeJpaRepository.deleteAllByCommentAuthorUserId(userId); + commentJpaRepository.softDeleteAllByUserId(userId); + } + + private PostJpaEntity findPostJpaEntity(PostType postType, Long postId) { + return switch (postType) { + case FEED -> feedJpaRepository.findByPostId(postId) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + case RECORD -> recordJpaRepository.findByPostId(postId) + .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); + case VOTE -> voteJpaRepository.findByPostId(postId) + .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); + }; + } } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeCommandPersistenceAdapter.java index 53c774908..ba0d47c3f 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeCommandPersistenceAdapter.java @@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; + import static konkuk.thip.common.exception.code.ErrorCode.*; @Repository @@ -47,4 +49,17 @@ public void deleteAllByCommentId(Long commentId) { commentLikeJpaRepository.deleteAllByCommentId(commentId); } + @Override + public void deleteAllByUserId(Long userId) { + // 1. 탈퇴 유저가 좋아요 누른 댓글 ID 리스트 조회 + List likedCommentIds = commentLikeJpaRepository.findAllCommentIdsByUserId(userId); + if (likedCommentIds == null || likedCommentIds.isEmpty()) { + return; //early return + } + // 2. 탈퇴한 유저의 모든 댓글 좋아요 관계 삭제 + commentLikeJpaRepository.deleteAllByLikerUserId(userId); + // 3. 탈퇴 유저가 좋아요 누른 댓글의 좋아요 수 감소 + commentJpaRepository.bulkDecrementLikeCountByIds(likedCommentIds); + } + } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java index aee0ebe3c..98048b3ac 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface CommentJpaRepository extends JpaRepository, CommentQueryRepository { @@ -19,4 +20,21 @@ public interface CommentJpaRepository extends JpaRepository findAllCommentsWithPostsByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE CommentJpaEntity c SET c.status = 'INACTIVE' WHERE c.postJpaEntity.postId IN :postIds") + void softDeleteAllByPostIds(@Param("postIds") List postIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE CommentJpaEntity c " + + "SET c.likeCount = CASE WHEN c.likeCount > 0 THEN c.likeCount - 1 ELSE 0 END " + + "WHERE c.commentId IN :likedCommentIds") + void bulkDecrementLikeCountByIds(@Param("likedCommentIds") List likedCommentIds); } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java index 8e4d9bb35..41bbe4ca2 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java @@ -40,4 +40,31 @@ WHERE cl.commentJpaEntity.commentId IN ( ) """) void deleteAllByPostId(@Param("postId") Long postId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM CommentLikeJpaEntity cl + WHERE cl.commentJpaEntity.commentId IN ( + SELECT c.commentId FROM CommentJpaEntity c + WHERE c.userJpaEntity.userId = :userId + ) + """) + void deleteAllByCommentAuthorUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM CommentLikeJpaEntity cl WHERE cl.userJpaEntity.userId = :userId") + void deleteAllByLikerUserId(@Param("userId") Long userId); + + @Query("SELECT cl.commentJpaEntity.commentId FROM CommentLikeJpaEntity cl WHERE cl.userJpaEntity.userId = :userId") + List findAllCommentIdsByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM CommentLikeJpaEntity cl + WHERE cl.commentJpaEntity.commentId IN ( + SELECT c.commentId FROM CommentJpaEntity c + WHERE c.postJpaEntity.postId IN :postIds + ) + """) + void deleteAllByPostIds(@Param("postIds") List postIds); } diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentCommandPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentCommandPort.java index 81eccc8ee..ac1d87bee 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentCommandPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentCommandPort.java @@ -24,4 +24,6 @@ default Comment getByIdOrThrow(Long id) { void delete(Comment comment); void softDeleteAllByPostId(Long postId); + + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeCommandPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeCommandPort.java index d776cef26..6303235d8 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeCommandPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeCommandPort.java @@ -5,4 +5,5 @@ public interface CommentLikeCommandPort { void save(Long userId, Long commentId); void delete(Long userId, Long commentId); void deleteAllByCommentId(Long commentId); + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 7e340d759..98c8308f3 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -23,6 +23,7 @@ public enum ErrorCode implements ResponseCode { AUTH_LOGIN_FAILED(HttpStatus.UNAUTHORIZED, 40104, "로그인에 실패했습니다."), AUTH_UNSUPPORTED_SOCIAL_LOGIN(HttpStatus.UNAUTHORIZED, 40105, "지원하지 않는 소셜 로그인입니다."), AUTH_INVALID_LOGIN_TOKEN_KEY(HttpStatus.UNAUTHORIZED, 40106, "유효하지 않은 로그인 토큰 키입니다."), + AUTH_BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, 40107, "블랙리스트에 등록된 토큰입니다."), JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 50100, "JSON 직렬화/역직렬화에 실패했습니다."), AWS_BUCKET_BASE_URL_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, 50101, "aws s3 bucket base url 설정이 누락되었습니다."), @@ -48,6 +49,9 @@ public enum ErrorCode implements ResponseCode { USER_NICKNAME_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, 70006, "다른 사용자가 이미 사용중인 닉네임입니다."), USER_ALREADY_SIGNED_UP(HttpStatus.BAD_REQUEST, 70007, "이미 가입된 사용자입니다."), USER_NOT_SIGNED_UP(HttpStatus.BAD_REQUEST, 70008, "가입되지 않은 사용자입니다."), + USER_CANNOT_DELETE_ROOM_HOST(HttpStatus.BAD_REQUEST, 70009, "모집/진행 중인 방의 방장은 회원탈퇴를 할 수 없습니다."), + USER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, 70010, "이미 삭제된 사용자 입니다."), + USER_OAUTH2ID_CANNOT_BE_NULL(HttpStatus.INTERNAL_SERVER_ERROR, 70011, "유저의 OAuth2Id 값이 null일 수 없습니다."), /** * 75000 : follow error diff --git a/src/main/java/konkuk/thip/common/security/annotation/AuthToken.java b/src/main/java/konkuk/thip/common/security/annotation/AuthToken.java new file mode 100644 index 000000000..1506d1ac9 --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/annotation/AuthToken.java @@ -0,0 +1,10 @@ +package konkuk.thip.common.security.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthToken {} diff --git a/src/main/java/konkuk/thip/common/security/argument_resolver/AuthTokenArgumentResolver.java b/src/main/java/konkuk/thip/common/security/argument_resolver/AuthTokenArgumentResolver.java new file mode 100644 index 000000000..672b12bf3 --- /dev/null +++ b/src/main/java/konkuk/thip/common/security/argument_resolver/AuthTokenArgumentResolver.java @@ -0,0 +1,40 @@ +package konkuk.thip.common.security.argument_resolver; + +import jakarta.servlet.http.HttpServletRequest; +import konkuk.thip.common.exception.AuthException; +import konkuk.thip.common.security.annotation.AuthToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import static konkuk.thip.common.exception.code.ErrorCode.AUTH_TOKEN_NOT_FOUND; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AuthTokenArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthToken.class) + && parameter.getParameterType().equals(String.class); + } + + @Override + public String resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + + Object token = ((HttpServletRequest) webRequest.getNativeRequest()).getAttribute("token"); + if (token == null) { + throw new AuthException(AUTH_TOKEN_NOT_FOUND); + } + return (String) token; + } +} diff --git a/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java b/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java index 397c6c576..688986eef 100644 --- a/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java +++ b/src/main/java/konkuk/thip/common/security/constant/AuthParameters.java @@ -12,6 +12,7 @@ public enum AuthParameters { GOOGLE_PROVIDER_ID_KEY("sub"), JWT_ACCESS_TOKEN_KEY("userId"), JWT_SIGNUP_TOKEN_KEY("oauth2Id"), + JWT_TOKEN_ATTRIBUTE("token"), REDIRECT_SIGNUP_URL("/signup"), REDIRECT_HOME_URL("/feed"), diff --git a/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java b/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java index 32f8fd290..47cabf33b 100644 --- a/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java @@ -8,6 +8,7 @@ import konkuk.thip.common.security.oauth2.CustomOAuth2User; import konkuk.thip.common.security.oauth2.LoginUser; import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.user.application.port.UserTokenBlacklistQueryPort; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -27,6 +28,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; + private final UserTokenBlacklistQueryPort userTokenBlacklistQueryPort; @Override protected void doFilterInternal(HttpServletRequest request, @@ -38,6 +40,10 @@ protected void doFilterInternal(HttpServletRequest request, throw new AuthException(AUTH_TOKEN_NOT_FOUND); } + if (userTokenBlacklistQueryPort.isTokenBlacklisted(token)) { + throw new AuthException(AUTH_BLACKLIST_TOKEN); + } + if (!jwtUtil.validateToken(token)) { throw new AuthException(AUTH_INVALID_TOKEN); } @@ -46,6 +52,7 @@ protected void doFilterInternal(HttpServletRequest request, throw new AuthException(AUTH_EXPIRED_TOKEN); } + request.setAttribute(JWT_TOKEN_ATTRIBUTE.getValue(), token); LoginUser loginUser = jwtUtil.getLoginUser(token); if (loginUser.userId() != null) { diff --git a/src/main/java/konkuk/thip/common/security/util/JwtUtil.java b/src/main/java/konkuk/thip/common/security/util/JwtUtil.java index 90024d7a2..8be9688a2 100644 --- a/src/main/java/konkuk/thip/common/security/util/JwtUtil.java +++ b/src/main/java/konkuk/thip/common/security/util/JwtUtil.java @@ -1,9 +1,6 @@ package konkuk.thip.common.security.util; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.*; import konkuk.thip.common.security.oauth2.LoginUser; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -86,4 +83,17 @@ public LoginUser getLoginUser(String token) { } return LoginUser.createExistingUser(oauth2Id, userId); } + + public Date getExpirationAllowExpired(String token) { + try { + Jws jwt = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return jwt.getPayload().getExpiration(); + } catch (ExpiredJwtException e) { + return e.getClaims().getExpiration(); + } + } + } diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index a8d26d610..3482586cc 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -35,7 +35,12 @@ public enum SwaggerResponseDescription { USER_NICKNAME_UPDATE_TOO_FREQUENT, USER_NICKNAME_ALREADY_EXISTS ))), - + USER_DELETE(new LinkedHashSet<>(Set.of( + USER_CANNOT_DELETE_ROOM_HOST, + USER_NOT_FOUND, + USER_ALREADY_DELETED, + USER_OAUTH2ID_CANNOT_BE_NULL + ))), // Follow CHANGE_FOLLOW_STATE(new LinkedHashSet<>(Set.of( @@ -348,6 +353,7 @@ public enum SwaggerResponseDescription { // AUTH_TOKEN_NOT_FOUND // AUTH_LOGIN_FAILED, // AUTH_UNSUPPORTED_SOCIAL_LOGIN, +// AUTH_BLACKLIST_TOKEN, // JSON_PROCESSING_ERROR ))); diff --git a/src/main/java/konkuk/thip/config/WebMvcConfig.java b/src/main/java/konkuk/thip/config/WebMvcConfig.java index 12d47b9a4..44a410d9e 100644 --- a/src/main/java/konkuk/thip/config/WebMvcConfig.java +++ b/src/main/java/konkuk/thip/config/WebMvcConfig.java @@ -1,5 +1,6 @@ package konkuk.thip.config; +import konkuk.thip.common.security.argument_resolver.AuthTokenArgumentResolver; import konkuk.thip.common.security.argument_resolver.Oauth2IdArgumentResolver; import konkuk.thip.common.security.argument_resolver.UserIdArgumentResolver; import lombok.RequiredArgsConstructor; @@ -15,10 +16,12 @@ public class WebMvcConfig implements WebMvcConfigurer { private final UserIdArgumentResolver userIdArgumentResolver; private final Oauth2IdArgumentResolver oauth2IdArgumentResolver; + private final AuthTokenArgumentResolver authTokenArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(userIdArgumentResolver); resolvers.add(oauth2IdArgumentResolver); + resolvers.add(authTokenArgumentResolver); } } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java index d2f2c8520..f9427b0a8 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java @@ -40,10 +40,6 @@ public class FeedJpaEntity extends PostJpaEntity { @Column(name = "content_list", columnDefinition = "TEXT") private ContentList contentList = ContentList.empty(); - // 삭제용 피드 저장 양방향 매핑 관계 - @OneToMany(mappedBy = "feedJpaEntity", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List savedFeeds = new ArrayList<>(); - @Column(name = "tag_list", columnDefinition = "TEXT") @Convert(converter = TagListJsonConverter.class) private TagList tagList = TagList.empty(); diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java index 792b63b55..5b9e3e7e6 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java @@ -2,6 +2,8 @@ import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.feed.adapter.out.jpa.*; import konkuk.thip.feed.adapter.out.mapper.FeedMapper; @@ -9,11 +11,13 @@ import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.feed.domain.Feed; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import static konkuk.thip.common.exception.code.ErrorCode.*; @@ -27,6 +31,10 @@ public class FeedCommandPersistenceAdapter implements FeedCommandPort { private final BookJpaRepository bookJpaRepository; private final SavedFeedJpaRepository savedFeedJpaRepository; + private final CommentJpaRepository commentJpaRepository; + private final CommentLikeJpaRepository commentLikeJpaRepository; + private final PostLikeJpaRepository postLikeJpaRepository; + private final FeedMapper feedMapper; @Override @@ -80,6 +88,31 @@ public void deleteSavedFeed(Long userId, Long feedId) { savedFeedJpaRepository.deleteByUserIdAndFeedId(userId, feedId); } + @Override + public void deleteAllSavedFeedByUserId(Long userId) { + savedFeedJpaRepository.deleteAllByUserId(userId); + } + + @Override + public void deleteAllFeedByUserId(Long userId) { + // 1. 유저가 작성한 피드 게시글 ID 리스트 조회 + List feedIds = feedJpaRepository.findFeedIdsByUserId(userId); + // 1. 유저가 작성한 피드 게시글 ID 리스트 조회 + if (feedIds == null || feedIds.isEmpty()) { + return; // early return + } + // 2-1. 댓글 좋아요 일괄 삭제 + commentLikeJpaRepository.deleteAllByPostIds(feedIds); + // 2-2. 댓글 soft delete 일괄 처리 + commentJpaRepository.softDeleteAllByPostIds(feedIds); + // 3. 게시글 좋아요 일괄 삭제 + postLikeJpaRepository.deleteAllByPostIds(feedIds); + // 4. 피드 저장 일괄 삭제 + savedFeedJpaRepository.deleteAllByFeedIds(feedIds); + // 5. 탈퇴한 유저가 작성한 피드 게시글 soft delete 일괄 처리 + feedJpaRepository.softDeleteAllByUserId(userId); + } + @Override public void delete(Feed feed) { FeedJpaEntity feedJpaEntity = feedJpaRepository.findByPostId(feed.getId()) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java index a46ee9d21..2c84f0a14 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java @@ -2,9 +2,11 @@ import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface FeedJpaRepository extends JpaRepository, FeedQueryRepository { @@ -19,4 +21,12 @@ public interface FeedJpaRepository extends JpaRepository, F @Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId AND f.isPublic = TRUE") long countPublicFeedsByUserId(@Param("userId") Long userId); + + @Query("SELECT f.postId FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId") + List findFeedIdsByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE FeedJpaEntity f SET f.status = 'INACTIVE' WHERE f.userJpaEntity.userId = :userId") + void softDeleteAllByUserId(@Param("userId") Long userId); + } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java index c10a53f7d..a348d5398 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java @@ -25,7 +25,15 @@ public interface SavedFeedJpaRepository extends JpaRepository 0 THEN true ELSE false END FROM SavedFeedJpaEntity s " + "WHERE s.userJpaEntity.userId = :userId AND s.feedJpaEntity.postId = :feedId") boolean existsByUserIdAndFeedId(@Param("userId") Long userId, @Param("feedId") Long feedId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.feedJpaEntity.postId IN :feedIds") + void deleteAllByFeedIds(@Param("feedIds") List feedIds); } diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java index 40dea9122..1839a4b5c 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java @@ -19,4 +19,6 @@ default Feed getByIdOrThrow(Long id) { void delete(Feed feed); void saveSavedFeed(Long userId, Long feedId); void deleteSavedFeed(Long userId, Long feedId); + void deleteAllSavedFeedByUserId(Long userId); + void deleteAllFeedByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java b/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java index b8074ca53..e463ff697 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java +++ b/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java @@ -1,16 +1,13 @@ package konkuk.thip.post.adapter.out.jpa; import jakarta.persistence.*; -import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; import konkuk.thip.common.entity.BaseJpaEntity; import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; +import lombok.Setter; import static konkuk.thip.common.entity.StatusType.INACTIVE; import static konkuk.thip.common.exception.code.ErrorCode.POST_ALREADY_DELETED; @@ -31,8 +28,18 @@ public abstract class PostJpaEntity extends BaseJpaEntity { @Column(length = 6100, nullable = false) protected String content; + /** + * -- SETTER -- + * 회원 탈퇴용 + */ + @Setter protected Integer likeCount = 0; + /** + * -- SETTER -- + * 회원 탈퇴용 + */ + @Setter protected Integer commentCount = 0; // type 구분을 위한 조회용 컬럼 @@ -43,14 +50,6 @@ public abstract class PostJpaEntity extends BaseJpaEntity { @JoinColumn(name = "user_id", nullable = false) private UserJpaEntity userJpaEntity; - // 삭제용 게시물 댓글 양방향 매핑 관계 - @OneToMany(mappedBy = "postJpaEntity", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List commentList = new ArrayList<>(); - - // 삭제용 게시물 좋아요 양방향 매핑 관계 - @OneToMany(mappedBy = "postJpaEntity", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List postLikeList = new ArrayList<>(); - public PostJpaEntity(String content, Integer likeCount, Integer commentCount, UserJpaEntity userJpaEntity) { this.content = content; this.likeCount = likeCount; diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java index e4f9d2057..b7d61e7d8 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java @@ -1,11 +1,14 @@ package konkuk.thip.post.adapter.out.persistence; import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.post.domain.PostType; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.post.adapter.out.mapper.PostLikeMapper; import konkuk.thip.post.application.port.out.PostLikeCommandPort; +import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; @@ -13,6 +16,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + import static konkuk.thip.common.exception.code.ErrorCode.*; import static konkuk.thip.common.exception.code.ErrorCode.RECORD_NOT_FOUND; import static konkuk.thip.common.exception.code.ErrorCode.VOTE_NOT_FOUND; @@ -49,14 +58,30 @@ public void deleteAllByPostId(Long postId) { postLikeJpaRepository.deleteAllByPostId(postId); } + @Override + public void deleteAllByUserId(Long userId) { + // 1. 탈퇴 유저가 좋아요한 게시글을 JOIN 조회 + List likedPosts = postLikeJpaRepository.findAllPostsWithTypeByUserId(userId); + if (likedPosts == null || likedPosts.isEmpty()) { + return; // early return + } + + // 2. 게시글 좋아요 수 감소 + for (PostJpaEntity post : likedPosts) { + post.setLikeCount(Math.max(0, post.getLikeCount() - 1)); + } + + // 3. 탈퇴한 유저의 모든 게시글 좋아요 삭제 + postLikeJpaRepository.deleteAllByUserId(userId); + } private PostJpaEntity findPostJpaEntity(PostType postType, Long postId) { return switch (postType) { - case FEED -> feedJpaRepository.findById(postId) + case FEED -> feedJpaRepository.findByPostId(postId) .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); - case RECORD -> recordJpaRepository.findById(postId) + case RECORD -> recordJpaRepository.findByPostId(postId) .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); - case VOTE -> voteJpaRepository.findById(postId) + case VOTE -> voteJpaRepository.findByPostId(postId) .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); }; } diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java index 8b291e553..6eeee5f44 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java @@ -1,5 +1,6 @@ package konkuk.thip.post.adapter.out.persistence; +import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -7,6 +8,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Set; @Repository @@ -28,4 +30,15 @@ Set findPostIdsLikedByUser(@Param("postIds") Set postIds, @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("DELETE FROM PostLikeJpaEntity pl WHERE pl.postJpaEntity.postId = :postId") void deleteAllByPostId(@Param("postId") Long postId); + + @Query("SELECT pl.postJpaEntity FROM PostLikeJpaEntity pl JOIN pl.postJpaEntity WHERE pl.userJpaEntity.userId = :userId") + List findAllPostsWithTypeByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM PostLikeJpaEntity pl WHERE pl.userJpaEntity.userId = :userId") + void deleteAllByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM PostLikeJpaEntity pl WHERE pl.postJpaEntity.postId IN :postIds") + void deleteAllByPostIds(@Param("postIds") List postIds); } diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java index 6c6421d14..28830badf 100644 --- a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java @@ -6,4 +6,5 @@ public interface PostLikeCommandPort { void save(Long userId, Long postId, PostType postType); void delete(Long userId, Long postId); void deleteAllByPostId(Long postId); + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java index d57ca22a4..e7245f24f 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java @@ -56,5 +56,10 @@ public void touch(RecentSearch recentSearch) { recentSearchJpaRepository.updateModifiedAt(recentSearch.getId()); } + @Override + public void deleteAllByUserId(Long userId) { + recentSearchJpaRepository.deleteAllByUserId(userId); + } + } diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java index 654b18972..6e7c23ded 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java @@ -12,4 +12,8 @@ public interface RecentSearchJpaRepository extends JpaRepository roomIds = roomParticipantJpaRepository.findRoomIdsByUserId(userId); + if (roomIds.isEmpty()) { + return; // early return + } + // 2. 유저의 모든 방 참여 관계 일괄 삭제 + roomParticipantJpaRepository.softDeleteAllByUserId(userId); + + // 3. 남은 ACTIVE 참여자 기준 방별 평균/인원 집계 + List stats = roomParticipantJpaRepository.aggregateStatsByRoomIds(roomIds); + + // 4. 방 정보(진행률, 멤버수) 업데이트 + for (RoomAggregateProjection row : stats) { + roomJpaRepository.updateRoomStats( + row.getRoomId(), + row.getAvgPercentage() == null ? 0.0 : row.getAvgPercentage(), + row.getMemberCount().intValue() + ); + } + } + @Override public Optional findByUserIdAndRoomIdOptional(Long userId, Long roomId) { return roomParticipantJpaRepository.findByUserIdAndRoomId(userId, roomId) diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/projection/RoomAggregateProjection.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/projection/RoomAggregateProjection.java new file mode 100644 index 000000000..34670215d --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/projection/RoomAggregateProjection.java @@ -0,0 +1,7 @@ +package konkuk.thip.room.adapter.out.persistence.projection; + +public interface RoomAggregateProjection { + Long getRoomId(); + Double getAvgPercentage(); + Long getMemberCount(); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java index cf88790eb..c58658c15 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java @@ -2,6 +2,7 @@ import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -20,4 +21,12 @@ public interface RoomJpaRepository extends JpaRepository, R "AND r.startDate > :currentDate") int countActiveRoomsByBookIdAndStartDateAfter(@Param("isbn") String isbn, @Param("currentDate") LocalDate currentDate); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update RoomJpaEntity r + set r.roomPercentage = :avg, + r.memberCount = :count + where r.roomId = :roomId + """) + void updateRoomStats(@Param("roomId") Long roomId, @Param("avg") double avg, @Param("count") int count); } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java index 22284a4d6..ef8120a16 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java @@ -1,7 +1,9 @@ package konkuk.thip.room.adapter.out.persistence.repository.roomparticipant; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.persistence.projection.RoomAggregateProjection; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -30,4 +32,29 @@ public interface RoomParticipantJpaRepository extends JpaRepository 0 THEN true ELSE false END " + + "FROM RoomParticipantJpaEntity rp " + + "JOIN RoomJpaEntity r ON rp.roomJpaEntity.roomId = r.roomId " + + "WHERE rp.userJpaEntity.userId = :userId " + + "AND rp.roomParticipantRole = 'HOST' " + + "AND (r.roomStatus = 'IN_PROGRESS' OR r.roomStatus = 'RECRUITING')") + boolean existsHostUserInActiveRoom(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE RoomParticipantJpaEntity rp SET rp.status = 'INACTIVE' WHERE rp.userJpaEntity.userId = :userId") + void softDeleteAllByUserId(@Param("userId") Long userId); + + @Query("SELECT rp.roomJpaEntity.roomId FROM RoomParticipantJpaEntity rp WHERE rp.userJpaEntity.userId = :userId") + List findRoomIdsByUserId(@Param("userId") Long userId); + + + @Query(""" + select rp.roomJpaEntity.roomId as roomId, + avg(rp.userPercentage) as avgPercentage, + count(rp) as memberCount + from RoomParticipantJpaEntity rp + where rp.roomJpaEntity.roomId in :roomIds + group by rp.roomJpaEntity.roomId + """) + List aggregateStatsByRoomIds(@Param("roomIds") List roomIds); } diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java index 519a66e43..81d93696f 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java @@ -24,4 +24,9 @@ default RoomParticipant getByUserIdAndRoomIdOrThrow(Long userId, Long roomId) { void deleteByUserIdAndRoomId(Long userId, Long roomId); void update(RoomParticipant roomParticipant); + + boolean existsHostUserInActiveRoom(Long userId); + + void deleteAllByUserId(Long userId); + } diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RoomPostSearchResponse.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RoomPostSearchResponse.java index 89add3a2f..ceaab0dde 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RoomPostSearchResponse.java +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RoomPostSearchResponse.java @@ -34,11 +34,11 @@ public record RoomPostSearchDto( public record VoteItemDto( Long voteItemId, String itemName, - int percentage, + int count, boolean isVoted ) { - public static VoteItemDto of(Long voteItemId, String itemName, int percentage, boolean isVoted) { - return new VoteItemDto(voteItemId, itemName, percentage, isVoted); + public static VoteItemDto of(Long voteItemId, String itemName, int count, boolean isVoted) { + return new VoteItemDto(voteItemId, itemName, count, isVoted); } } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/jpa/VoteItemJpaEntity.java b/src/main/java/konkuk/thip/roompost/adapter/out/jpa/VoteItemJpaEntity.java index 5312f5e70..da390d928 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/jpa/VoteItemJpaEntity.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/jpa/VoteItemJpaEntity.java @@ -21,6 +21,11 @@ public class VoteItemJpaEntity extends BaseJpaEntity { @Column(name = "item_name",length = 70, nullable = false) private String itemName; + /** + * -- SETTER -- + * 회원 탈퇴용 + */ + @Setter @Builder.Default @Column(nullable = false) private int count = 0; diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckCommandPersistenceAdapter.java index fa5ac8c4d..e48ff3e9c 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckCommandPersistenceAdapter.java @@ -55,4 +55,9 @@ public void delete(AttendanceCheck attendanceCheck) { attendanceCheckJpaRepository.delete(attendanceCheckJpaEntity); } + + @Override + public void deleteAllByUserId(Long userId) { + attendanceCheckJpaRepository.softDeleteAllByUserId(userId); + } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java index b327f9c1a..840f0d8ac 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java @@ -1,6 +1,9 @@ package konkuk.thip.roompost.adapter.out.persistence; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; import konkuk.thip.roompost.adapter.out.mapper.RecordMapper; import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; @@ -13,6 +16,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import static konkuk.thip.common.exception.code.ErrorCode.*; @@ -24,6 +28,11 @@ public class RecordCommandPersistenceAdapter implements RecordCommandPort { private final RecordJpaRepository recordJpaRepository; private final UserJpaRepository userJpaRepository; private final RoomJpaRepository roomJpaRepository; + + private final CommentJpaRepository commentJpaRepository; + private final CommentLikeJpaRepository commentLikeJpaRepository; + private final PostLikeJpaRepository postLikeJpaRepository; + private final RecordMapper recordMapper; @Override @@ -57,6 +66,23 @@ public void delete(Record record) { recordJpaRepository.save(recordJpaEntity); } + @Override + public void deleteAllByUserId(Long userId) { + // 1. 유저가 작성한 피드 게시글 ID 리스트 조회 + List recordIds = recordJpaRepository.findRecordIdsByUserId(userId); + if (recordIds == null || recordIds.isEmpty()) { + return; // early return + } + // 2-1. 댓글 좋아요 일괄 삭제 + commentLikeJpaRepository.deleteAllByPostIds(recordIds); + // 2-2. 댓글 soft delete 일괄 처리 + commentJpaRepository.softDeleteAllByPostIds(recordIds); + // 3. 게시글 좋아요 일괄 삭제 + postLikeJpaRepository.deleteAllByPostIds(recordIds); + // 4. 탈퇴한 유저가 작성한 기록 soft delete 일괄 처리 + recordJpaRepository.softDeleteAllByUserId(userId); + } + @Override public void update(Record record) { RecordJpaEntity recordJpaEntity = recordJpaRepository.findByPostId(record.getId()).orElseThrow( diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java index 3a753a395..8db4e64ae 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java @@ -1,6 +1,9 @@ package konkuk.thip.roompost.adapter.out.persistence; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; @@ -36,6 +39,10 @@ public class VoteCommandPersistenceAdapter implements VoteCommandPort { private final RoomJpaRepository roomJpaRepository; private final VoteParticipantJpaRepository voteParticipantJpaRepository; + private final CommentJpaRepository commentJpaRepository; + private final CommentLikeJpaRepository commentLikeJpaRepository; + private final PostLikeJpaRepository postLikeJpaRepository; + private final VoteMapper voteMapper; private final VoteItemMapper voteItemMapper; private final VoteParticipantMapper voteParticipantMapper; @@ -148,6 +155,41 @@ public void delete(Vote vote) { voteJpaRepository.save(voteJpaEntity); } + @Override + public void deleteAllVoteParticipantByUserId(Long userId) { + + // 1. 탈퇴 유저가 참여한 모든 투표 항목 ID 조회 + List voteItemIds = voteParticipantJpaRepository.findAllVoteItemIdsByUserId(userId); + if (voteItemIds == null || voteItemIds.isEmpty()) { + return; //early return + } + // 2. 투표 참여 관계 삭제 + voteParticipantJpaRepository.deleteAllByUserId(userId); + // 3. 탈퇴 유저가 투표 했던 투표 항목들의 득표 수 감소 + voteItemJpaRepository.bulkDecrementLikeCount(voteItemIds); + } + + @Override + public void deleteAllVoteByUserId(Long userId) { + // 1. 유저가 작성한 투표 게시글 ID 리스트 조회 + List voteIds = voteJpaRepository.findVoteIdsByUserId(userId); + if (voteIds == null || voteIds.isEmpty()) { + return; // early return + } + // 2-1. 댓글 좋아요 일괄 삭제 + commentLikeJpaRepository.deleteAllByPostIds(voteIds); + // 2-2. 댓글 soft delete 일괄 처리 + commentJpaRepository.softDeleteAllByPostIds(voteIds); + // 3. 게시글 좋아요 일괄 삭제 + postLikeJpaRepository.deleteAllByPostIds(voteIds); + // 4-1. 투표 참여 관계 일괄 삭제 + voteParticipantJpaRepository.deleteAllByVoteIds(voteIds); + // 4-2. 투표 항목 일괄 삭제 + voteItemJpaRepository.deleteAllByVoteIds(voteIds); + // 5. 탈퇴한 유저가 작성한 투표 soft delete 일괄 처리 + voteJpaRepository.softDeleteAllByUserId(userId); + } + @Override public void updateVote(Vote vote) { diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java index 4d3766ac7..6714cf2ca 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckJpaRepository.java @@ -2,6 +2,7 @@ import konkuk.thip.roompost.adapter.out.jpa.AttendanceCheckJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -22,4 +23,8 @@ public interface AttendanceCheckJpaRepository extends JpaRepository= :startOfDay " + "AND a.createdAt < :endOfDay") int countByUserIdAndRoomIdAndCreatedAtBetween(@Param("userId") Long userId, @Param("roomId") Long roomId, @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE AttendanceCheckJpaEntity a SET a.status = 'INACTIVE' WHERE a.userJpaEntity.userId = :userId") + void softDeleteAllByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java index 7f684dca8..9677e8263 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java @@ -2,7 +2,11 @@ import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface RecordJpaRepository extends JpaRepository, RecordQueryRepository { @@ -11,4 +15,11 @@ public interface RecordJpaRepository extends JpaRepository findByPostId(Long postId); + + @Query("SELECT r.postId FROM RecordJpaEntity r WHERE r.userJpaEntity.userId = :userId") + List findRecordIdsByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE RecordJpaEntity r SET r.status = 'INACTIVE' WHERE r.userJpaEntity.userId = :userId") + void softDeleteAllByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteItemJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteItemJpaRepository.java index b8640c6b2..5c07e4ac0 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteItemJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteItemJpaRepository.java @@ -15,4 +15,14 @@ public interface VoteItemJpaRepository extends JpaRepository voteIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE VoteItemJpaEntity vi " + + "SET vi.count = CASE WHEN vi.count > 0 THEN vi.count - 1 ELSE 0 END " + + "WHERE vi.voteItemId IN :voteItemIds") + void bulkDecrementLikeCount(@Param("voteItemIds") List voteItemIds); } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java index 1ec6e367e..90251801d 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java @@ -1,8 +1,12 @@ package konkuk.thip.roompost.adapter.out.persistence.repository.vote; +import io.lettuce.core.dynamic.annotation.Param; import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import java.util.List; import java.util.Optional; public interface VoteJpaRepository extends JpaRepository, VoteQueryRepository { @@ -11,4 +15,11 @@ public interface VoteJpaRepository extends JpaRepository, V * 소프트 딜리트 적용 대상 entity 단건 조회 메서드 */ Optional findByPostId(Long postId); + + @Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.userJpaEntity.userId = :userId") + List findVoteIdsByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE VoteJpaEntity v SET v.status = 'INACTIVE' WHERE v.userJpaEntity.userId = :userId") + void softDeleteAllByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteParticipantJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteParticipantJpaRepository.java index f92a674cc..204a04926 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteParticipantJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteParticipantJpaRepository.java @@ -7,12 +7,13 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface VoteParticipantJpaRepository extends JpaRepository, VoteParticipantQueryRepository { @Query("SELECT vp FROM VoteParticipantJpaEntity vp WHERE vp.userJpaEntity.userId = :userId AND vp.voteItemJpaEntity.voteItemId = :voteItemId") - Optional findVoteParticipantByUserIdAndVoteItemId(Long userId, Long voteItemId); + Optional findVoteParticipantByUserIdAndVoteItemId(@Param("userId") Long userId,@Param("voteItemId") Long voteItemId); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" @@ -25,4 +26,21 @@ WHERE vp.voteItemJpaEntity.voteItemId IN ( """) void deleteAllByVoteId(@Param("voteId") Long voteId); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM VoteParticipantJpaEntity vp + WHERE vp.voteItemJpaEntity.voteItemId IN ( + SELECT vi.voteItemId + FROM VoteItemJpaEntity vi + WHERE vi.voteJpaEntity.postId IN :voteIds + ) + """) + void deleteAllByVoteIds(@Param("voteIds") List voteIds); + + @Query("SELECT vp.voteItemJpaEntity.voteItemId FROM VoteParticipantJpaEntity vp WHERE vp.userJpaEntity.userId = :userId") + List findAllVoteItemIdsByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM VoteParticipantJpaEntity vp WHERE vp.userJpaEntity.userId = :userId") + void deleteAllByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/dto/vote/VoteResult.java b/src/main/java/konkuk/thip/roompost/application/port/in/dto/vote/VoteResult.java index 6fa404f49..e64dc3889 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/in/dto/vote/VoteResult.java +++ b/src/main/java/konkuk/thip/roompost/application/port/in/dto/vote/VoteResult.java @@ -10,11 +10,11 @@ public record VoteResult( public record VoteItemDto( Long voteItemId, String itemName, - int percentage, + int count, Boolean isVoted ) { - public static VoteItemDto of(Long voteItemId, String itemName, int percentage, Boolean isVoted) { - return new VoteItemDto(voteItemId, itemName, percentage, isVoted); + public static VoteItemDto of(Long voteItemId, String itemName, int count, Boolean isVoted) { + return new VoteItemDto(voteItemId, itemName, count, isVoted); } } diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckCommandPort.java index 018d17ca0..2df49ed18 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckCommandPort.java @@ -19,4 +19,6 @@ default AttendanceCheck getByIdOrThrow(Long id) { } void delete(AttendanceCheck attendanceCheck); + + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java index 16b5ceb9a..505a769ce 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java @@ -22,4 +22,6 @@ default Record getByIdOrThrow(Long id) { } void delete(Record record); + + void deleteAllByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java index 18051c628..de09c2614 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java @@ -46,4 +46,8 @@ default VoteItem getVoteItemByIdOrThrow(Long id) { void updateVoteItem(VoteItem voteItem); void delete(Vote vote); + + void deleteAllVoteParticipantByUserId(Long userId); + + void deleteAllVoteByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/application/service/RecordDeleteService.java b/src/main/java/konkuk/thip/roompost/application/service/RecordDeleteService.java index 4388472c0..f84c3f007 100644 --- a/src/main/java/konkuk/thip/roompost/application/service/RecordDeleteService.java +++ b/src/main/java/konkuk/thip/roompost/application/service/RecordDeleteService.java @@ -43,7 +43,6 @@ public Long deleteRecord(RecordDeleteCommand command) { // 3-3. 기록 삭제 recordCommandPort.delete(record); - //TODO// 4. 유저 방 진행도 업데이트 return command.roomId(); } } diff --git a/src/main/java/konkuk/thip/roompost/application/service/RoomPostSearchService.java b/src/main/java/konkuk/thip/roompost/application/service/RoomPostSearchService.java index 8ff8e82af..2caf740a0 100644 --- a/src/main/java/konkuk/thip/roompost/application/service/RoomPostSearchService.java +++ b/src/main/java/konkuk/thip/roompost/application/service/RoomPostSearchService.java @@ -20,7 +20,6 @@ import konkuk.thip.room.domain.RoomParticipant; import konkuk.thip.roompost.application.port.out.VoteQueryPort; import konkuk.thip.roompost.application.port.out.dto.VoteItemQueryDto; -import konkuk.thip.roompost.domain.VoteItem; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -144,20 +143,12 @@ private List getVoteItemDt // VoteItemQueryDto 목록을 RecordSearchResponse.PostDto.VoteItemDto 목록으로 변환하는 메서드 private List mapToVoteItemDtos(List items, boolean isLocked) { - // voteCount를 모아 리스트로 변환 - List counts = items.stream() - .map(VoteItemQueryDto::voteCount) - .toList(); - - // 도메인에게 계산 위임 - List percentages = VoteItem.calculatePercentages(counts); - // 계산 결과를 이용해 DTO 조립 return IntStream.range(0, items.size()) .mapToObj(i -> RoomPostSearchResponse.RoomPostSearchDto.VoteItemDto.of( items.get(i).voteItemId(), isLocked ? roomPostAccessValidator.createBlurredString(items.get(i).itemName()) : items.get(i).itemName(), - percentages.get(i), + items.get(i).voteCount(), items.get(i).isVoted() )) .toList(); diff --git a/src/main/java/konkuk/thip/roompost/application/service/VoteDeleteService.java b/src/main/java/konkuk/thip/roompost/application/service/VoteDeleteService.java index d0ac331d1..5fa410ef6 100644 --- a/src/main/java/konkuk/thip/roompost/application/service/VoteDeleteService.java +++ b/src/main/java/konkuk/thip/roompost/application/service/VoteDeleteService.java @@ -41,7 +41,6 @@ public Long deleteVote(VoteDeleteCommand command) { // 3-3. 투표 삭제 voteCommandPort.delete(vote); - //TODO// 4. 유저 방 진행도 업데이트 return command.roomId(); } } diff --git a/src/main/java/konkuk/thip/roompost/application/service/VoteService.java b/src/main/java/konkuk/thip/roompost/application/service/VoteService.java index 22ee0fab6..58487d2db 100644 --- a/src/main/java/konkuk/thip/roompost/application/service/VoteService.java +++ b/src/main/java/konkuk/thip/roompost/application/service/VoteService.java @@ -42,17 +42,12 @@ public VoteResult vote(VoteCommand command) { // 2. 투표 결과 반환 List voteItems = voteQueryPort.findVoteItemsByVoteId(command.voteId(), command.userId()); - List counts = voteItems.stream() - .map(VoteItemQueryDto::voteCount) - .toList(); - - List percentages = VoteItem.calculatePercentages(counts); var voteItemDtos = IntStream.range(0, voteItems.size()) .mapToObj(i -> VoteResult.VoteItemDto.of( voteItems.get(i).voteItemId(), voteItems.get(i).itemName(), - percentages.get(i), + voteItems.get(i).voteCount(), voteItems.get(i).isVoted() )) .toList(); diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java index ecca0b84a..e5f9044b4 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.AuthToken; import konkuk.thip.common.security.annotation.Oauth2Id; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; @@ -13,6 +14,7 @@ import konkuk.thip.user.adapter.in.web.request.UserUpdateRequest; import konkuk.thip.user.adapter.in.web.response.UserFollowResponse; import konkuk.thip.user.adapter.in.web.response.UserSignupResponse; +import konkuk.thip.user.application.port.in.UserDeleteUseCase; import konkuk.thip.user.application.port.in.UserFollowUsecase; import konkuk.thip.user.application.port.in.UserSignupUseCase; import konkuk.thip.user.application.port.in.UserUpdateUseCase; @@ -29,6 +31,7 @@ public class UserCommandController { private final UserSignupUseCase userSignupUseCase; private final UserFollowUsecase userFollowUsecase; private final UserUpdateUseCase userUpdateUseCase; + private final UserDeleteUseCase userDeleteUseCase; @Operation( @@ -74,4 +77,17 @@ public BaseResponse updateUser( userUpdateUseCase.updateUser(userUpdateRequest.toCommand(userId)); return BaseResponse.ok(null); } + + @Operation( + summary = "회원 탈퇴", + description = "사용자가 회원탈퇴를 합니다. 사용자와 관련된 정보가 모두 삭제되고," + + "회원탈퇴한 사용자의 토큰을 블랙리스트에 추가하여 서비스 재진입을 막습니다." + ) + @ExceptionDescription(USER_DELETE) + @DeleteMapping("/users") + public BaseResponse deleteUser(@Parameter(hidden = true) @UserId final Long userId, + @Parameter(hidden = true) @AuthToken final String authToken) { + userDeleteUseCase.deleteUser(userId,authToken); + return BaseResponse.ok(null); + } } diff --git a/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java index 1acef21e1..603a12913 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java +++ b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; +import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.user.domain.value.Alias; import konkuk.thip.user.domain.User; import lombok.*; @@ -10,6 +11,9 @@ import java.time.LocalDate; +import static konkuk.thip.common.entity.StatusType.INACTIVE; +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_DELETED; + @Entity @Table(name = "users") @Getter @@ -33,6 +37,11 @@ public class UserJpaEntity extends BaseJpaEntity { @Column(name = "oauth2_id", length = 50, nullable = false) private String oauth2Id; + /** + * -- SETTER -- + * 회원 탈퇴용 + */ + @Setter @Builder.Default private Integer followerCount = 0; // 팔로워 수 @@ -58,4 +67,13 @@ public void updateFrom(User user) { this.role = UserRole.from(user.getUserRole()); this.followerCount = user.getFollowerCount(); } + + public void softDelete(User user) { + if(this.status.equals(INACTIVE)){ + throw new InvalidStateException(USER_ALREADY_DELETED); + } + this.status = INACTIVE; + this.oauth2Id = user.getOauth2Id(); + } + } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingCommandPersistenceAdapter.java index 2d7ce7cee..b2b6cd4b6 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingCommandPersistenceAdapter.java @@ -13,7 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import java.util.Optional; +import java.util.*; import static konkuk.thip.common.exception.code.ErrorCode.FOLLOW_NOT_FOUND; import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; @@ -54,6 +54,23 @@ public void deleteFollowing(Following following, User targetUser) { followingJpaRepository.delete(followingJpaEntity); } + @Override + public void deleteAllByUserId(Long userId) { + // 유저가 팔로워인 팔로잉 관계 -> 유저가 팔로우 중인 유저들의 팔로워 수 감소 -> 관계 삭제 + // 유저를 팔로우 하는 팔로잉 관계 -> 관계 삭제 + + // 1. 탈퇴 유저가 팔로우 중인 유저들 ID 조회 + List targetUserIds = userJpaRepository.findAllTargetUserIdsByUserId(userId); + // 2. 탈퇴한 유저의 모든 팔로잉 관계 삭제 + followingJpaRepository.deleteAllByUserIdOrFollowingUserId(userId); + if (targetUserIds == null || targetUserIds.isEmpty()) { + return; //early return + } + // 3. 탈퇴 유저가 팔로우 중인 유저들의 팔로워 수 감소 + followingJpaRepository.bulkDecrementFollowerCount(targetUserIds); + } + + private UserJpaEntity updateUserFollowerCount(User targetUser) { UserJpaEntity userJpaEntity = userJpaRepository.findByUserId(targetUser.getId()).orElseThrow( () -> new EntityNotFoundException(USER_NOT_FOUND) diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java index d141c6808..774ae5481 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java @@ -55,4 +55,14 @@ public void update(User user) { userJpaEntity.updateIncludeAliasFrom(user); userJpaRepository.save(userJpaEntity); } + + @Override + public void delete(User user) { + UserJpaEntity userJpaEntity = userJpaRepository.findByUserId(user.getId()).orElseThrow( + () -> new EntityNotFoundException(USER_NOT_FOUND) + ); + + userJpaEntity.softDelete(user); + userJpaRepository.save(userJpaEntity); + } } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserTokenBlacklistRedisAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserTokenBlacklistRedisAdapter.java new file mode 100644 index 000000000..0c2a72949 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserTokenBlacklistRedisAdapter.java @@ -0,0 +1,79 @@ +package konkuk.thip.user.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import konkuk.thip.common.exception.ExternalApiException; +import konkuk.thip.common.security.oauth2.LoginUser; +import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.user.application.port.UserTokenBlacklistCommandPort; +import konkuk.thip.user.application.port.UserTokenBlacklistQueryPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.JSON_PROCESSING_ERROR; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserTokenBlacklistRedisAdapter implements UserTokenBlacklistQueryPort, UserTokenBlacklistCommandPort { + + private final RedisTemplate redisTemplate; + + @Value("${app.redis.token-blacklist-prefix}") + private String blacklistPrefix; + + private final JwtUtil jwtUtil; + + //블랙리스트에 토큰 추가 (토큰 만료시간에 맞춰 자동 소멸) + @Override + public void addTokenToBlacklist(String token) { + Date expiration = jwtUtil.getExpirationAllowExpired(token); + LoginUser loginUser = jwtUtil.getLoginUser(token); + LocalDateTime withdrawalTime = LocalDateTime.now(); + String key = makeBlacklistKey(token); + + Map valueMap = new HashMap<>(); + valueMap.put("userId", loginUser.userId()); + valueMap.put("withdrawalTime", withdrawalTime); + String valueJson = null; + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + valueJson = mapper.writeValueAsString(valueMap); + } catch (JsonProcessingException e) { + throw new ExternalApiException(JSON_PROCESSING_ERROR); + } + redisTemplate.opsForValue().set(key, valueJson); + log.info("블랙리스트에 탈퇴한 회원 토큰 및 관련 정보 추가 - userId: {}, withdrawalTime: {}, expiration: {}", + loginUser.userId(), + withdrawalTime, + expiration + ); + // 토큰 만료시각으로 Redis에 expireAt 지정 (만료 이후 Redis에서 자동 삭제) + redisTemplate.expireAt(key, expiration.toInstant()); + } + + // 토큰이 블랙리스트에 등록되어있는지 체크 + @Override + public boolean isTokenBlacklisted(String token) { + String key = makeBlacklistKey(token); + return redisTemplate.hasKey(key); + } + + // JWT 블랙리스트 Redis Key 생성 규칙 + private String makeBlacklistKey(String token) { + return blacklistPrefix + ":" + token; + } + +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java index 214ea6009..497296710 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java @@ -2,7 +2,10 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface UserJpaRepository extends JpaRepository, UserQueryRepository { @@ -19,4 +22,7 @@ public interface UserJpaRepository extends JpaRepository, U boolean existsByNicknameAndUserIdNot(String nickname, Long userId); boolean existsByOauth2Id(String oauth2Id); + + @Query("SELECT f.followingUserJpaEntity.userId FROM FollowingJpaEntity f WHERE f.userJpaEntity.userId = :userId") + List findAllTargetUserIdsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingJpaRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingJpaRepository.java index 0ceae15ab..0ddc40bdf 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingJpaRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingJpaRepository.java @@ -2,15 +2,30 @@ import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +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; + @Repository public interface FollowingJpaRepository extends JpaRepository, FollowingQueryRepository { @Query("SELECT COUNT(f) > 0 FROM FollowingJpaEntity f WHERE f.userJpaEntity.userId = :userId AND f.followingUserJpaEntity.userId = :followingUserId") - boolean existsByUserIdAndFollowingUserId(Long userId, Long followingUserId); + boolean existsByUserIdAndFollowingUserId(@Param("userId") Long userId, @Param("followingUserId") Long followingUserId); @Query("SELECT COUNT(f) FROM FollowingJpaEntity f WHERE f.userJpaEntity.userId = :userId") - int countFollowingByUserId(Long userId); + int countFollowingByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM FollowingJpaEntity f WHERE f.userJpaEntity.userId = :userId OR f.followingUserJpaEntity.userId = :userId") + void deleteAllByUserIdOrFollowingUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE UserJpaEntity u " + + "SET u.followerCount = CASE WHEN u.followerCount > 0 THEN u.followerCount - 1 ELSE 0 END " + + "WHERE u.userId IN :targetUserIds" + ) + void bulkDecrementFollowerCount(@Param("targetUserIds") List targetUserIds); } diff --git a/src/main/java/konkuk/thip/user/application/port/UserTokenBlacklistCommandPort.java b/src/main/java/konkuk/thip/user/application/port/UserTokenBlacklistCommandPort.java new file mode 100644 index 000000000..0105ddb1e --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/UserTokenBlacklistCommandPort.java @@ -0,0 +1,5 @@ +package konkuk.thip.user.application.port; + +public interface UserTokenBlacklistCommandPort { + void addTokenToBlacklist(String token); +} diff --git a/src/main/java/konkuk/thip/user/application/port/UserTokenBlacklistQueryPort.java b/src/main/java/konkuk/thip/user/application/port/UserTokenBlacklistQueryPort.java new file mode 100644 index 000000000..570a69017 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/UserTokenBlacklistQueryPort.java @@ -0,0 +1,5 @@ +package konkuk.thip.user.application.port; + +public interface UserTokenBlacklistQueryPort { + boolean isTokenBlacklisted(String token); +} diff --git a/src/main/java/konkuk/thip/user/application/port/in/UserDeleteUseCase.java b/src/main/java/konkuk/thip/user/application/port/in/UserDeleteUseCase.java new file mode 100644 index 000000000..d84641187 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/UserDeleteUseCase.java @@ -0,0 +1,5 @@ +package konkuk.thip.user.application.port.in; + +public interface UserDeleteUseCase { + void deleteUser(Long userId, String authToken); +} diff --git a/src/main/java/konkuk/thip/user/application/port/out/FollowingCommandPort.java b/src/main/java/konkuk/thip/user/application/port/out/FollowingCommandPort.java index c126b73ce..0dff90fd7 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/FollowingCommandPort.java +++ b/src/main/java/konkuk/thip/user/application/port/out/FollowingCommandPort.java @@ -19,4 +19,7 @@ default Following getByUserIdAndTargetUserIdOrThrow(Long userId, Long targetUser void save(Following following, User targetUser); void deleteFollowing(Following following, User targetUser); + + void deleteAllByUserId(Long userId); + } diff --git a/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java b/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java index 3d8fd2793..ed45f3593 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java +++ b/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java @@ -11,4 +11,5 @@ public interface UserCommandPort { User findById(Long userId); Map findByIds(List userIds); void update(User user); + void delete(User user); } diff --git a/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java new file mode 100644 index 000000000..842f91dd3 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java @@ -0,0 +1,94 @@ +package konkuk.thip.user.application.service; + +import konkuk.thip.book.application.port.out.BookCommandPort; +import konkuk.thip.comment.application.port.out.CommentCommandPort; +import konkuk.thip.comment.application.port.out.CommentLikeCommandPort; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.feed.application.port.out.FeedCommandPort; +import konkuk.thip.post.application.port.out.PostLikeCommandPort; +import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort; +import konkuk.thip.room.application.port.out.RoomParticipantCommandPort; +import konkuk.thip.roompost.application.port.out.AttendanceCheckCommandPort; +import konkuk.thip.roompost.application.port.out.RecordCommandPort; +import konkuk.thip.roompost.application.port.out.VoteCommandPort; +import konkuk.thip.user.application.port.UserTokenBlacklistCommandPort; +import konkuk.thip.user.application.port.in.UserDeleteUseCase; +import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import konkuk.thip.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_DELETE_ROOM_HOST; + +@Service +@RequiredArgsConstructor +public class UserDeleteService implements UserDeleteUseCase { + + private final UserCommandPort userCommandPort; + private final FollowingCommandPort followingCommandPort; + private final FeedCommandPort feedCommandPort; + private final BookCommandPort bookCommandPort; + private final VoteCommandPort voteCommandPort; + private final CommentCommandPort commentCommandPort; + private final PostLikeCommandPort postLikeCommandPort; + private final RecordCommandPort recordCommandPort; + private final CommentLikeCommandPort commentLikeCommandPort; + private final RecentSearchCommandPort recentSearchCommandPort; + private final AttendanceCheckCommandPort attendanceCheckCommandPort; + private final RoomParticipantCommandPort roomParticipantCommandPort; + + private final UserTokenBlacklistCommandPort userTokenBlacklistCommandPort; + + @Override + @Transactional + public void deleteUser(Long userId, String authToken) { + + // 1. 진행/모집 중인 방의 호스트일경우 회원탈퇴 불가 + boolean isHostInActiveRoom = roomParticipantCommandPort.existsHostUserInActiveRoom(userId); + if (isHostInActiveRoom) { + throw new BusinessException(USER_CANNOT_DELETE_ROOM_HOST); + } + + // 2. 유저 조회 및 검증 + User user = userCommandPort.findById(userId); + user.markAsDeleted(); + + // 3. 유저가 남긴 관련 정보들 삭제 + // 팔로잉 관계 삭제 + followingCommandPort.deleteAllByUserId(userId); + // 최근검색어 삭제 + recentSearchCommandPort.deleteAllByUserId(userId); + // 알림 삭제 // TODO 알림구현 적용되면 수정 + // notificationCommandPort.softDeleteAllByUserId(userId); + // 책/피드 저장 관계 삭제 + feedCommandPort.deleteAllSavedFeedByUserId(userId); + bookCommandPort.deleteAllSavedBookByUserId(userId); + // 오늘의 한마디 관계 삭제 + attendanceCheckCommandPort.deleteAllByUserId(userId); + // 투표 참여 관계 삭제 -> 투표한 항목의 득표 수 감소 + voteCommandPort.deleteAllVoteParticipantByUserId(userId); + // 댓글 좋아요 삭제 -> 댓글의 좋아요 수 감소 + commentLikeCommandPort.deleteAllByUserId(userId); + // 댓글 삭제 -> 게시글의 댓글 수 감소, 댓글의 좋아요 삭제 + commentCommandPort.deleteAllByUserId(userId); + // 게시글 좋아요 삭제 -> 게시글의 좋아요 수 감소 + postLikeCommandPort.deleteAllByUserId(userId); + + // 피드 삭제 + feedCommandPort.deleteAllFeedByUserId(userId); + // 기록 삭제 + recordCommandPort.deleteAllByUserId(userId); + // 투표 삭제 + voteCommandPort.deleteAllVoteByUserId(userId); + + // 방 참여 관계 삭제 + roomParticipantCommandPort.deleteAllByUserId(userId); + // 유저 삭제 + userCommandPort.delete(user); + // 토큰 블랙리스트 추가 + userTokenBlacklistCommandPort.addTokenToBlacklist(authToken); + } + +} diff --git a/src/main/java/konkuk/thip/user/domain/User.java b/src/main/java/konkuk/thip/user/domain/User.java index d6df512f8..ca2b08f28 100644 --- a/src/main/java/konkuk/thip/user/domain/User.java +++ b/src/main/java/konkuk/thip/user/domain/User.java @@ -9,6 +9,9 @@ import java.time.LocalDate; +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_DELETED; +import static konkuk.thip.common.exception.code.ErrorCode.USER_OAUTH2ID_CANNOT_BE_NULL; + @Getter @SuperBuilder public class User extends BaseDomainEntity { @@ -75,4 +78,14 @@ private void validateCanUpdateNickname(String nickname) { } } + public void markAsDeleted() { + if (this.oauth2Id == null) { + throw new InvalidStateException(USER_OAUTH2ID_CANNOT_BE_NULL); + } + if (this.oauth2Id.startsWith("deleted:")) { + throw new InvalidStateException(USER_ALREADY_DELETED); + } + this.oauth2Id = "deleted:" + this.oauth2Id; + } + } diff --git a/src/test/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapterTest.java b/src/test/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapterTest.java new file mode 100644 index 000000000..875360766 --- /dev/null +++ b/src/test/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapterTest.java @@ -0,0 +1,94 @@ +package konkuk.thip.comment.adapter.out.persistence; + +import jakarta.persistence.EntityManager; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.comment.adapter.out.mapper.CommentMapper; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.config.TestQuerydslConfig; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.post.domain.PostType; +import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; +import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import({TestQuerydslConfig.class, CommentCommandPersistenceAdapter.class}) +class CommentCommandPersistenceAdapterTest { + + @Autowired CommentCommandPersistenceAdapter adapter; // repository 인터페이스가 아니므로 자동 스캔 X -> import 해줘야함 + @Autowired CommentJpaRepository commentJpaRepository; + @Autowired CommentLikeJpaRepository commentLikeJpaRepository; + @Autowired BookJpaRepository bookJpaRepository; + @Autowired FeedJpaRepository feedJpaRepository; + @Autowired UserJpaRepository userJpaRepository; + @Autowired RecordJpaRepository recordJpaRepository; + @Autowired VoteJpaRepository voteJpaRepository; + + @MockitoBean CommentMapper commentMapper; // Mock bean 으로 설정 + + @Autowired EntityManager em; + + @Test + @DisplayName("deleteAllByUserId: Post.commentCount는 targetUser의 댓글 수만큼 감소하고, 해당 댓글들은 INACTIVE 처리된다.") + void deleteAllByUserId_updatesPostCount_andSoftDeletesComments() { + // given + UserJpaEntity targetUser = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + UserJpaEntity otherUser = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + + BookJpaEntity bookJpaEntity = bookJpaRepository.save(TestEntityFactory.createBook()); + FeedJpaEntity feedJpaEntity = feedJpaRepository.save(TestEntityFactory.createFeed(otherUser, bookJpaEntity, true)); + + CommentJpaEntity c1 = commentJpaRepository.save(TestEntityFactory.createComment(feedJpaEntity, targetUser, PostType.FEED)); + CommentJpaEntity c2 = commentJpaRepository.save(TestEntityFactory.createComment(feedJpaEntity, targetUser, PostType.FEED)); + CommentJpaEntity c3 = commentJpaRepository.save(TestEntityFactory.createComment(feedJpaEntity, otherUser, PostType.FEED)); + + // 피드의 commentCount update + feedJpaEntity.setCommentCount(3); + + // when + adapter.deleteAllByUserId(targetUser.getUserId()); + + // then + em.flush(); // 더티체킹 → DB 반영 + em.clear(); + + // 1) 게시글의 commentCount가 3 -> 1 로 감소했는지 확인 (targetUser 댓글 2개 삭제) + FeedJpaEntity updated = feedJpaRepository.findByPostId(feedJpaEntity.getPostId()).orElseThrow(); + assertThat(updated.getCommentCount()).isEqualTo(1); + + // 2) targetUser의 댓글은 INACTIVE, otherUser의 댓글은 ACTIVE + List all = commentJpaRepository.findAll(); // 상태 필터링 AOP 없다면 전부 보임 + long targetInactive = all.stream() + .filter(c -> c.getUserJpaEntity().getUserId().equals(targetUser.getUserId())) + .filter(c -> StatusType.INACTIVE.name().equals(c.getStatus().name())) // BaseJpaEntity.status 타입에 맞게 비교 + .count(); + + long otherActive = all.stream() + .filter(c -> c.getUserJpaEntity().getUserId().equals(otherUser.getUserId())) + .filter(c -> StatusType.ACTIVE.name().equals(c.getStatus().name())) + .count(); + + assertThat(targetInactive).isEqualTo(2); // targetUser가 단 댓글 2개 모두 INACTIVE + assertThat(otherActive).isEqualTo(1); // otherUser가 단 댓글 1개는 그대로 ACTIVE + } +} diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index 697458718..003c3ebdc 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -12,6 +12,8 @@ import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; import konkuk.thip.post.domain.PostType; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; @@ -279,49 +281,14 @@ public static FollowingJpaEntity createFollowing(UserJpaEntity followerUser, Use * 공개/비공개 여부만을 설정하는 기본 피드 생성을 위한 팩토리 메서드 */ public static FeedJpaEntity createFeed(UserJpaEntity user, BookJpaEntity book, boolean isPublic) { - -// return FeedJpaEntity.builder() -// .content("기본 피드 본문입니다.") -// .isPublic(isPublic) -// .likeCount(0) -// .commentCount(0) -// .reportCount(0) -// .userJpaEntity(user) -// .bookJpaEntity(book) -// .contentList(ContentList.empty()) -// .build(); return createFeed(user, book, isPublic, 0, 0, Collections.emptyList(), Collections.emptyList()); } public static FeedJpaEntity createFeed(UserJpaEntity user, BookJpaEntity book, boolean isPublic, int likeCount, int commentCount, List imageUrls) { - -// FeedJpaEntity feed = FeedJpaEntity.builder() -// .content("이미지 포함 피드") -// .isPublic(isPublic) -// .likeCount(0) -// .commentCount(0) -// .reportCount(0) -// .userJpaEntity(user) -// .bookJpaEntity(book) -// .contentList(ContentList.of(imageUrls)) -// .build(); -// return createFeed(user, book, isPublic, likeCount, commentCount, imageUrls, Collections.emptyList()); } public static FeedJpaEntity createFeed(UserJpaEntity user, BookJpaEntity book, List imageUrls, boolean isPublic) { - -// FeedJpaEntity feed = FeedJpaEntity.builder() -// .content("이미지 포함 피드") -// .isPublic(isPublic) -// .likeCount(0) -// .commentCount(0) -// .reportCount(0) -// .userJpaEntity(user) -// .bookJpaEntity(book) -// .contentList(ContentList.of(imageUrls)) -// .build(); -// return createFeed(user, book, isPublic, 0, 0, imageUrls, Collections.emptyList()); } @@ -391,4 +358,13 @@ public static AttendanceCheckJpaEntity createAttendanceCheck(String todayComment .userJpaEntity(userJpaEntity) .build(); } + + public static RecentSearchJpaEntity createRecentSearch(UserJpaEntity userJpaEntity) { + return RecentSearchJpaEntity.builder() + .searchTerm("테스트검색어") + .type(RecentSearchType.BOOK_SEARCH) + .userJpaEntity(userJpaEntity) + .build(); + } + } diff --git a/src/test/java/konkuk/thip/roompost/adapter/in/web/RoomPostSearchApiTest.java b/src/test/java/konkuk/thip/roompost/adapter/in/web/RoomPostSearchApiTest.java index c84b71227..2c1ff4f23 100644 --- a/src/test/java/konkuk/thip/roompost/adapter/in/web/RoomPostSearchApiTest.java +++ b/src/test/java/konkuk/thip/roompost/adapter/in/web/RoomPostSearchApiTest.java @@ -129,7 +129,7 @@ void searchGroupRecords_with_vote_and_record_success() throws Exception { for (JsonNode voteItem : voteItems) { assertThat(voteItem.path("voteItemId")).isNotNull(); assertThat(voteItem.path("itemName")).isNotNull(); - assertThat(voteItem.path("percentage")).isNotNull(); + assertThat(voteItem.path("count")).isNotNull(); assertThat(voteItem.path("isVoted")).isNotNull(); } } diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java new file mode 100644 index 000000000..f3e66fe8c --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java @@ -0,0 +1,469 @@ +package konkuk.thip.user.adapter.in.web; + +import jakarta.persistence.EntityManager; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.book.adapter.out.persistence.repository.SavedBookJpaRepository; +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.comment.adapter.out.jpa.CommentLikeJpaEntity; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; +import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; +import konkuk.thip.recentSearch.adapter.out.persistence.repository.RecentSearchJpaRepository; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomStatus; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.domain.value.Category; +import konkuk.thip.roompost.adapter.out.jpa.*; +import konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck.AttendanceCheckJpaRepository; +import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; +import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteItemJpaRepository; +import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteJpaRepository; +import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteParticipantJpaRepository; +import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; +import konkuk.thip.user.application.port.UserTokenBlacklistQueryPort; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static konkuk.thip.common.entity.StatusType.INACTIVE; +import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_DELETE_ROOM_HOST; +import static konkuk.thip.post.domain.PostType.*; +import static konkuk.thip.room.adapter.out.jpa.RoomParticipantRole.HOST; +import static konkuk.thip.room.adapter.out.jpa.RoomParticipantRole.MEMBER; +import static konkuk.thip.room.adapter.out.jpa.RoomStatus.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@DisplayName("[통합] 회원탈퇴 api 테스트") +public class UserDeleteApiTest { + + @Autowired private MockMvc mockMvc; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private CommentJpaRepository commentJpaRepository; + @Autowired private CommentLikeJpaRepository commentLikeJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + @Autowired private SavedFeedJpaRepository savedFeedJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired private VoteJpaRepository voteJpaRepository; + @Autowired private VoteItemJpaRepository voteItemJpaRepository; + @Autowired private FollowingJpaRepository followingJpaRepository; + @Autowired private RecentSearchJpaRepository recentSearchJpaRepository; + @Autowired private SavedBookJpaRepository savedBookJpaRepository; + @Autowired private AttendanceCheckJpaRepository attendanceCheckJpaRepository; + @Autowired private VoteParticipantJpaRepository voteParticipantJpaRepository; + @Autowired private RecordJpaRepository recordJpaRepository; + @Autowired private UserTokenBlacklistQueryPort userTokenBlacklistQueryPort; + + @Autowired private JwtUtil jwtUtil; + @Autowired private EntityManager entityManager; + + @AfterEach + void tearDown() { + followingJpaRepository.deleteAllInBatch(); + recentSearchJpaRepository.deleteAllInBatch(); + savedFeedJpaRepository.deleteAllInBatch(); + savedBookJpaRepository.deleteAllInBatch(); + attendanceCheckJpaRepository.deleteAllInBatch(); + voteParticipantJpaRepository.deleteAllInBatch(); + commentLikeJpaRepository.deleteAllInBatch(); + commentJpaRepository.deleteAllInBatch(); + postLikeJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + voteItemJpaRepository.deleteAllInBatch(); + voteJpaRepository.deleteAllInBatch(); + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + } + + @Test + @DisplayName("회원탈퇴 성공시 모든 연관 엔티티가 각 엔티티 삭제 전략에 맞게 삭제되고 탈퇴한 회원의 토큰이 블랙리스트에 등록된다.") + void deleteUser_success() throws Exception { + + // given + // 유저 설정 1:테스트하고자하는 유저 2:방의 호스트 유저 3:방의 멤버유저 + UserJpaEntity testUser1 = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST)); + UserJpaEntity otherHostUser2 = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST)); + UserJpaEntity otherMemberUser3 = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST)); + + // 팔로잉 관계 설정 + FollowingJpaEntity u1_u2_f1 = followingJpaRepository.save(TestEntityFactory.createFollowing(testUser1, otherHostUser2)); // 유저 1이 유저 2 팔로우 + FollowingJpaEntity u3_u1_f2 = followingJpaRepository.save(TestEntityFactory.createFollowing(otherMemberUser3, testUser1)); // 유저 3이 유저 1팔로우 + followingJpaRepository.save(TestEntityFactory.createFollowing(otherMemberUser3, otherHostUser2)); // 유저 3이 유저 2팔로우 + otherHostUser2.setFollowerCount(2); userJpaRepository.save(otherHostUser2); //유저 2 팔로워수 2 + testUser1.setFollowerCount(1); userJpaRepository.save(testUser1); //유저 1 팔로워수 1 + + // 최근검색어 저장 + recentSearchJpaRepository.save(TestEntityFactory.createRecentSearch(testUser1)); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + Category category = TestEntityFactory.createLiteratureCategory(); + // 방 참여 관계 설정: 모든 유저가 방에 참여해있음 + RoomJpaEntity roomExpired1 = createRoom(book,category, EXPIRED); + RoomJpaEntity roomInProgress2 = createRoom(book,category, IN_PROGRESS); + RoomJpaEntity roomRecruiting3 = createRoom(book,category, RECRUITING); + + // 방1-> 만료된 방 : 유저1이 HOST + RoomParticipantJpaEntity r1_rp1 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomExpired1,testUser1,HOST,50.0)); + RoomParticipantJpaEntity r1_rp2 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomExpired1,otherHostUser2, MEMBER,30.0)); + RoomParticipantJpaEntity r1_rp3 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomExpired1,otherMemberUser3,MEMBER,60.0)); + updateRoomPercentage(r1_rp1,r1_rp2,r1_rp3,roomExpired1); + // 방2-> 진행중인 방 : 유저2가 HOST + RoomParticipantJpaEntity r2_rp1 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomInProgress2,testUser1,MEMBER,50.0)); + RoomParticipantJpaEntity r2_rp2 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomInProgress2,otherHostUser2,HOST,30.0)); + RoomParticipantJpaEntity r2_rp3 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomInProgress2,otherMemberUser3,MEMBER,60.0)); + updateRoomPercentage(r2_rp1,r2_rp2,r2_rp3,roomInProgress2); + // 방3 -> 모집중인 방 : 유저2가 HOST + RoomParticipantJpaEntity r3_rp1 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomRecruiting3,testUser1,MEMBER,50.0)); + RoomParticipantJpaEntity r3_rp2 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomRecruiting3,otherHostUser2,HOST,30.0)); + RoomParticipantJpaEntity r3_rp3 = roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomRecruiting3,otherMemberUser3,MEMBER,60.0)); + updateRoomPercentage(r3_rp1,r3_rp2,r3_rp3,roomRecruiting3); + + // 피드/책 저장관계 설정 + FeedJpaEntity u2_f1 = feedJpaRepository.save(TestEntityFactory.createFeed(otherHostUser2,book,true)); //유저 2가 피드1 작성 + savedFeedJpaRepository.save(TestEntityFactory.createSavedFeed(testUser1,u2_f1)); //유저 1이 유저2가 작성한 피드1를 저장 + + BookJpaEntity sb = bookJpaRepository.save(TestEntityFactory.createBook()); + savedBookJpaRepository.save(TestEntityFactory.createSavedBook(testUser1,sb)); + + // 오늘의 한마디 관계 설정 + AttendanceCheckJpaEntity a = attendanceCheckJpaRepository.save(TestEntityFactory.createAttendanceCheck + ("유저1이 방2에 남긴 오늘의한마디1",roomInProgress2,testUser1)); + + //유저 2가 방2에 투표1 생성 + VoteJpaEntity u2_v1 = voteJpaRepository.save( + VoteJpaEntity.builder().content("유저2가 방2에 생성한 투표1").userJpaEntity(otherHostUser2).page(33).isOverview(true) + .commentCount(2).likeCount(1).roomJpaEntity(roomInProgress2).build()); + VoteItemJpaEntity v1_vi1 = voteItemJpaRepository.save( + VoteItemJpaEntity.builder().itemName("유저2가 방2에 생성한 투표1의 항목1").count(1).voteJpaEntity(u2_v1).build()); + VoteItemJpaEntity v1_vi2 = voteItemJpaRepository.save( + VoteItemJpaEntity.builder().itemName("유저2가 방2에 생성한 투표1의 항목2").count(0).voteJpaEntity(u2_v1).build()); + VoteParticipantJpaEntity u1_vp1 = voteParticipantJpaRepository.save(TestEntityFactory.createVoteParticipant(testUser1, v1_vi1)); //유저1이 투표1의 투표항목1에 투표 + CommentJpaEntity u2_v1_c1 = commentJpaRepository.save( + CommentJpaEntity.builder().content("유저2가 투표1에 남긴 댓글1").postJpaEntity(u2_v1).userJpaEntity(otherHostUser2) + .likeCount(1).reportCount(0).postType(VOTE).build()); //유저2가 투표1에 댓글 + // 유저1의 댓글 좋아요 관계 설정 + CommentLikeJpaEntity u1_c1_cl1 = commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(u2_v1_c1, testUser1)); //유저1이 댓글1에 댓글좋아요 + + //유저2가 방2에 기록1 생성 + RecordJpaEntity u2_r1 = recordJpaRepository.save( + RecordJpaEntity.builder().content("유저2가 방2에 생성한 기록1").userJpaEntity(otherHostUser2).page(22).isOverview(false) + .commentCount(1).likeCount(1).roomJpaEntity(roomInProgress2).build()); + + // 유저1의 게시글 좋아요 관계 설정 + PostLikeJpaEntity u1_v1_pl1 = postLikeJpaRepository.save(TestEntityFactory.createPostLike(testUser1,u2_v1)); //유저1이 투표1에 좋아요 + PostLikeJpaEntity u1_f1_pl2 = postLikeJpaRepository.save(TestEntityFactory.createPostLike(testUser1,u2_f1)); //유저1이 피드1에 좋아요 + PostLikeJpaEntity u1_r1_pl3 = postLikeJpaRepository.save(TestEntityFactory.createPostLike(testUser1,u2_r1)); //유저1이 기록1에 좋아요 + + // 유저1의 댓글 저장 + CommentJpaEntity u1_v1_c2 = commentJpaRepository.save( + CommentJpaEntity.builder().content("유저1이 투표1에 남긴 댓글2").postJpaEntity(u2_v1).userJpaEntity(testUser1) + .likeCount(1).reportCount(0).postType(VOTE).build()); //유저1이 투표1에 댓글 + CommentJpaEntity u1_f1_c3 = commentJpaRepository.save( + CommentJpaEntity.builder().content("유저1이 피드1에 남긴 댓글3").postJpaEntity(u2_f1).userJpaEntity(testUser1) + .likeCount(1).reportCount(0).postType(FEED).build()); //유저1이 피드1에 댓글 + CommentJpaEntity u1_r1_c4 = commentJpaRepository.save( + CommentJpaEntity.builder().content("유저1이 기록1에 남긴 댓글4").postJpaEntity(u2_r1).userJpaEntity(testUser1) + .likeCount(1).reportCount(0).postType(RECORD).build()); //유저1이 기록1에 댓글 + + u2_f1.setLikeCount(1); //피드 1 좋아요/댓글 씽크 맞추기 + u2_f1.setCommentCount(1); + feedJpaRepository.save(u2_f1); + + // 유저1이 남긴 댓글에 대해 댓글 좋아요 관계 설정 : 유저2가 유저1이 남긴 댓글에 대해 좋아요 + CommentLikeJpaEntity u2_c2_cl = commentLikeJpaRepository.save( + TestEntityFactory.createCommentLike(u1_v1_c2, otherHostUser2)); + CommentLikeJpaEntity u2_c3_cl = commentLikeJpaRepository.save( + TestEntityFactory.createCommentLike(u1_f1_c3, otherHostUser2)); + CommentLikeJpaEntity u2_c4_cl = commentLikeJpaRepository.save( + TestEntityFactory.createCommentLike(u1_r1_c4, otherHostUser2)); + + // 유저1이 작성한 게시물 관계 설정 + // 유저 1이 피드2 작성 + FeedJpaEntity u1_f2 = feedJpaRepository.save(TestEntityFactory.createFeed(testUser1,book,true)); + CommentJpaEntity u2_f3_c4 = commentJpaRepository.save( + CommentJpaEntity.builder().content("유저2이 피드2에 남긴 댓글4").postJpaEntity(u1_f2).userJpaEntity(otherHostUser2) + .likeCount(1).reportCount(0).postType(FEED).build()); //유저2이 피드2에 댓글 + //유저3이 댓글4에 댓글좋아요 + CommentLikeJpaEntity u3_c4_cl2 = commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(u2_f3_c4, otherMemberUser3)); + PostLikeJpaEntity u2_f2_pl4 = postLikeJpaRepository.save(TestEntityFactory.createPostLike(otherHostUser2, u1_f2)); //유저2가 피드2에 좋아요 + savedFeedJpaRepository.save(TestEntityFactory.createSavedFeed(otherHostUser2,u1_f2)); //유저2가 피드2를 저장 + u1_f2.setLikeCount(1); //피드 2 좋아요/댓글 씽크 맞추기 + u1_f2.setCommentCount(1); + feedJpaRepository.save(u1_f2); + + // 유저 1이 투표2 작성 + VoteJpaEntity u1_v2 = voteJpaRepository.save( + VoteJpaEntity.builder().content("유저1가 방2에 생성한 투표2").userJpaEntity(testUser1).page(33).isOverview(true) + .commentCount(1).likeCount(1).roomJpaEntity(roomInProgress2).build()); + VoteItemJpaEntity v2_vi1 = voteItemJpaRepository.save( + VoteItemJpaEntity.builder().itemName("유저1이 방2에 생성한 투표2의 항목1").count(1).voteJpaEntity(u1_v2).build()); + VoteItemJpaEntity v2_vi2 = voteItemJpaRepository.save( + VoteItemJpaEntity.builder().itemName("유저1이 방2에 생성한 투표2의 항목2").count(1).voteJpaEntity(u1_v2).build()); + VoteParticipantJpaEntity u2_vp2 = voteParticipantJpaRepository.save(TestEntityFactory.createVoteParticipant(otherHostUser2, v2_vi1)); //유저2이 투표2의 투표항목1에 투표 + VoteParticipantJpaEntity u3_vp3 = voteParticipantJpaRepository.save(TestEntityFactory.createVoteParticipant(otherMemberUser3, v2_vi2)); //유저3이 투표2의 투표항목2에 투표 + CommentJpaEntity u3_v2_c5 = commentJpaRepository.save( + CommentJpaEntity.builder().content("유저3이 투표2에 남긴 댓글5").postJpaEntity(u1_v2).userJpaEntity(otherMemberUser3) + .likeCount(1).reportCount(0).postType(VOTE).build()); //유저3이 투표2에 댓글 + //유저2이 댓글5에 댓글좋아요 + CommentLikeJpaEntity u2_c5_cl3 = commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(u3_v2_c5, otherHostUser2)); + PostLikeJpaEntity u3_v2_pl5 = postLikeJpaRepository.save(TestEntityFactory.createPostLike(otherMemberUser3, u1_v2)); //유저3이 투표2에 좋아요 + + // 유저 1이 기록2 작성 + RecordJpaEntity u1_r2 = recordJpaRepository.save( + RecordJpaEntity.builder().content("유저1이 방2에 생성한 기록2").userJpaEntity(testUser1).page(22).isOverview(false) + .commentCount(1).likeCount(1).roomJpaEntity(roomInProgress2).build()); + CommentJpaEntity u2_r2_c6 = commentJpaRepository.save( + CommentJpaEntity.builder().content("유저2가 기록2에 남긴 댓글6").postJpaEntity(u1_r2).userJpaEntity(otherHostUser2) + .likeCount(1).reportCount(0).postType(RECORD).build()); //유저2가 기록2에 댓글 + //유저3이 댓글6에 댓글좋아요 + CommentLikeJpaEntity u3_c6_cl4 = commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(u2_r2_c6, otherMemberUser3)); + PostLikeJpaEntity u2_r2_pl6 = postLikeJpaRepository.save(TestEntityFactory.createPostLike(otherHostUser2, u1_r2)); //유저2가 기록2에 좋아요 + + //when + String accessToken = jwtUtil.createAccessToken(testUser1.getUserId()); + mockMvc.perform(delete("/users") + .header("Authorization", "Bearer " + accessToken) //헤더 추가 + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + // then: 1) 유저 팔로잉/팔로워 관계 삭제 + // 탈퇴한 유저1의 팔로잉/팔로워 관계는 모두 삭제되어야하고, 관련 없는 유저3->유저2 팔로우관계만 남아있어야함 + // 유저2의 팔로워 수가 1이어야함 + assertTrue(followingJpaRepository.findById(u1_u2_f1.getFollowingId()).isEmpty()); + assertTrue(followingJpaRepository.findById(u3_u1_f2.getFollowingId()).isEmpty()); + UserJpaEntity updateUser2 = userJpaRepository.findById(otherHostUser2.getUserId()).orElse(null); + assertEquals(1, updateUser2.getFollowerCount()); + // 2) 최근검색어 삭제 + assertTrue(recentSearchJpaRepository.findAll().isEmpty()); + + // 3) 저장한 책/파드 삭제 + assertTrue(savedFeedJpaRepository.findAllByUserId(testUser1.getUserId()).isEmpty()); + assertTrue(savedBookJpaRepository.findAll().isEmpty()); + + // 4) 오늘의 한마디 관계 soft delete (status=INACTIVE) + AttendanceCheckJpaEntity deletedAc = attendanceCheckJpaRepository.findById(a.getAttendanceCheckId()).orElse(null); + assertThat(deletedAc.getStatus()).isEqualTo(INACTIVE); + + // 5) 유저 투표 참여 관계 삭제 + // 탈퇴한 유저가 참여한 투표관계 모두 삭제 + // 탈퇴한 유저가 참여했던 투표의 득표수 감소 + assertTrue(voteParticipantJpaRepository.findById(u1_vp1.getVoteParticipantId()).isEmpty()); + VoteItemJpaEntity updateVi1 = voteItemJpaRepository.findById(v1_vi1.getVoteItemId()).orElse(null); + assertEquals(0, updateVi1.getCount()); + + // 6) 유저 댓글 좋아요 삭제 + // 탈퇴한 유저의 모든 댓글 좋아요 관계 삭제 + // 탈퇴한 유저가 좋아요한 댓글의 좋아요 수 감소 + assertTrue(commentLikeJpaRepository.findById(u1_c1_cl1.getLikeId()).isEmpty()); + CommentJpaEntity updatedCm1 = commentJpaRepository.findById(u2_v1_c1.getCommentId()).orElse(null); + assertEquals(0, updatedCm1.getLikeCount()); + + // 7) 유저 댓글 soft delete (status=INACTIVE) + // 탈퇴한 유저의 모든 댓글 soft delete + // 탈퇴한 유저가 남긴 게시글의 댓글 수 감소 + // 탈퇴한 유저의 모든 댓글의 좋아요 관계 삭제 + CommentJpaEntity deletedCm1 = commentJpaRepository.findById(u1_v1_c2.getCommentId()).orElse(null); + CommentJpaEntity deletedCm2 = commentJpaRepository.findById(u1_f1_c3.getCommentId()).orElse(null); + CommentJpaEntity deletedCm3 = commentJpaRepository.findById(u1_r1_c4.getCommentId()).orElse(null); + assertThat(deletedCm1.getStatus()).isEqualTo(INACTIVE); + assertThat(deletedCm2.getStatus()).isEqualTo(INACTIVE); + assertThat(deletedCm3.getStatus()).isEqualTo(INACTIVE); + + VoteJpaEntity updateV1 = voteJpaRepository.findByPostId(u2_v1.getPostId()).orElse(null); + FeedJpaEntity updateF1 = feedJpaRepository.findByPostId(u2_f1.getPostId()).orElse(null); + RecordJpaEntity updateR1 = recordJpaRepository.findByPostId(u2_r1.getPostId()).orElse(null); + assertEquals(1, updateV1.getCommentCount()); // 유저2가 남긴 댓글은 남아있음 + assertEquals(0, updateF1.getCommentCount()); + assertEquals(0, updateR1.getCommentCount()); + + assertTrue(commentLikeJpaRepository.findById(u2_c2_cl.getLikeId()).isEmpty()); + assertTrue(commentLikeJpaRepository.findById(u2_c3_cl.getLikeId()).isEmpty()); + assertTrue(commentLikeJpaRepository.findById(u2_c4_cl.getLikeId()).isEmpty()); + + // 8) 유저 게시글 좋아요 삭제 + // 탈퇴한 유저가 남긴 모든 게시글 좋아요 삭제 + // 탈퇴한 유저가 남긴 게시글의 좋아요 수 감소 + assertTrue(postLikeJpaRepository.findById(u1_v1_pl1.getLikeId()).isEmpty()); + assertTrue(postLikeJpaRepository.findById(u1_f1_pl2.getLikeId()).isEmpty()); + assertTrue(postLikeJpaRepository.findById(u1_r1_pl3.getLikeId()).isEmpty()); + assertEquals(0, updateV1.getLikeCount()); + assertEquals(0, updateF1.getLikeCount()); + assertEquals(0, updateR1.getLikeCount()); + + // 9) 유저가 작성한 피드 soft delete (status=INACTIVE) + // 탈퇴한 유저가 작성한 모든 피드 soft delete + // 탈퇴한 유저가 작성한 모든 피드의 댓글 좋아요 삭제 + // 탈퇴한 유저가 작성한 모든 피드의 댓글 soft delete + // 탈퇴한 유저가 작성한 모든 피드 좋아요 삭제 + // 탈퇴한 유저가 작성한 피드를 저장하는 모든 관계 삭제 + FeedJpaEntity deletedF1 = feedJpaRepository.findById(u1_f2.getPostId()).orElse(null); + assertThat(deletedF1.getStatus()).isEqualTo(INACTIVE); + assertTrue(commentLikeJpaRepository.findById(u3_c4_cl2.getLikeId()).isEmpty()); + CommentJpaEntity deletedCm4 = commentJpaRepository.findById(u2_f3_c4.getCommentId()).orElse(null); + assertThat(deletedCm4.getStatus()).isEqualTo(INACTIVE); + assertTrue(postLikeJpaRepository.findById(u2_f2_pl4.getLikeId()).isEmpty()); + assertTrue(savedFeedJpaRepository.findAllByUserId(otherHostUser2.getUserId()).isEmpty()); + + // 9) 유저가 작성한 투표 soft delete (status=INACTIVE) + // 탈퇴한 유저가 작성한 모든 투표 soft delete + // 탈퇴한 유저가 작성한 모든 투표의 댓글 좋아요 삭제 + // 탈퇴한 유저가 작성한 모든 투표의 댓글 soft delete + // 탈퇴한 유저가 작성한 모든 투표 좋아요 삭제 + // 탈퇴한 유저가 작성한 투표에 참여하는 모든 관계를 삭제 + // 탈퇴한 유저가 작성한 모든 투표의 투표 항목 삭제 + VoteJpaEntity deletedV1 = voteJpaRepository.findById(u1_v2.getPostId()).orElse(null); + assertThat(deletedV1.getStatus()).isEqualTo(INACTIVE); + assertTrue(commentLikeJpaRepository.findById(u2_c5_cl3.getLikeId()).isEmpty()); + CommentJpaEntity deletedCm5 = commentJpaRepository.findById(u3_v2_c5.getCommentId()).orElse(null); + assertThat(deletedCm5.getStatus()).isEqualTo(INACTIVE); + assertTrue(postLikeJpaRepository.findById(u3_v2_pl5.getLikeId()).isEmpty()); + assertTrue(voteParticipantJpaRepository.findById(u2_vp2.getVoteParticipantId()).isEmpty()); + assertTrue(voteParticipantJpaRepository.findById(u3_vp3.getVoteParticipantId()).isEmpty()); + assertTrue(voteItemJpaRepository.findById(v2_vi1.getVoteItemId()).isEmpty()); + assertTrue(voteItemJpaRepository.findById(v2_vi2.getVoteItemId()).isEmpty()); + + // 10) 유저가 작성한 기록 soft delete (status=INACTIVE) + // 탈퇴한 유저가 작성한 모든 기록 soft delete + // 탈퇴한 유저가 작성한 모든 기록의 댓글 좋아요 삭제 + // 탈퇴한 유저가 작성한 모든 기록의 댓글 soft delete + // 탈퇴한 유저가 작성한 모든 기록 좋아요 삭제 + RecordJpaEntity deletedR1 = recordJpaRepository.findById(u1_r2.getPostId()).orElse(null); + assertThat(deletedR1.getStatus()).isEqualTo(INACTIVE); + assertTrue(commentLikeJpaRepository.findById(u3_c6_cl4.getLikeId()).isEmpty()); + CommentJpaEntity deletedCm6 = commentJpaRepository.findById(u2_r2_c6.getCommentId()).orElse(null); + assertThat(deletedCm6.getStatus()).isEqualTo(INACTIVE); + assertTrue(postLikeJpaRepository.findById(u2_r2_pl6.getLikeId()).isEmpty()); + + // 11) 유저가 참여한 방 관계 soft delete (status=INACTIVE) + // 탈퇴한 유저가 참여한 모든 방 관계 soft delete + // 탈퇴한 유저가 참여한 모든 방의 멤버수 감소 + // 탈퇴한 유저가 참여한 모든 방의 진행도 업데이트 + RoomParticipantJpaEntity deletedRp1 = roomParticipantJpaRepository. + findById(r1_rp1.getRoomParticipantId()).orElse(null); //만료된 방에서는 host가 나가도 상관없음 + RoomParticipantJpaEntity deletedRp2 = roomParticipantJpaRepository. + findById(r2_rp1.getRoomParticipantId()).orElse(null); + RoomParticipantJpaEntity deletedRp3 = roomParticipantJpaRepository. + findById(r3_rp1.getRoomParticipantId()).orElse(null); + assertThat(deletedRp1.getStatus()).isEqualTo(INACTIVE); + assertThat(deletedRp2.getStatus()).isEqualTo(INACTIVE); + assertThat(deletedRp3.getStatus()).isEqualTo(INACTIVE); + assertEquals(2, roomJpaRepository.findByRoomId(roomExpired1.getRoomId()).get().getMemberCount()); + assertEquals(45.0, roomJpaRepository.findByRoomId(roomExpired1.getRoomId()).get().getRoomPercentage()); + assertEquals(2, roomJpaRepository.findByRoomId(roomInProgress2.getRoomId()).get().getMemberCount()); + assertEquals(45.0, roomJpaRepository.findByRoomId(roomInProgress2.getRoomId()).get().getRoomPercentage()); + assertEquals(2, roomJpaRepository.findByRoomId(roomRecruiting3.getRoomId()).get().getMemberCount()); + assertEquals(45.0, roomJpaRepository.findByRoomId(roomRecruiting3.getRoomId()).get().getRoomPercentage()); + + // 12) 유저 soft delete (status=INACTIVE) + // 탈퇴한 유저의 oauth2Id는 deleted:로 시작해야함 + entityManager.clear(); + UserJpaEntity deletedUser = userJpaRepository.findById(testUser1.getUserId()).orElse(null); + assertThat(deletedUser.getStatus()).isEqualTo(INACTIVE); + assertThat(deletedUser.getOauth2Id()).startsWith("deleted:"); + + // 13) 탈퇴한 유저의 토큰을 블랙리스트 등록 검증 + assertTrue(userTokenBlacklistQueryPort.isTokenBlacklisted(accessToken)); + } + + @Test + @DisplayName("회원탈퇴 시 진행/모집 중인 방의 호스트라면 [400 에러 발생]") + void deleteUser_whenHostInActiveRoom_thenThrowBusinessException() throws Exception { + + // given + UserJpaEntity hostUser1 = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST)); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + Category category = TestEntityFactory.createLiteratureCategory(); + RoomJpaEntity roomInProgress = createRoom(book, category, IN_PROGRESS); + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomInProgress, hostUser1, HOST, 100.0)); + + // when + String accessToken1 = jwtUtil.createAccessToken(hostUser1.getUserId()); + // then + mockMvc.perform(delete("/users") + .header("Authorization", "Bearer " + accessToken1) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(USER_CANNOT_DELETE_ROOM_HOST.getCode())); + + // given + UserJpaEntity hostUser2 = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST)); + RoomJpaEntity roomInRecruiting = createRoom(book, category, RECRUITING); + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(roomInRecruiting, hostUser2, HOST, 100.0)); + + // when + String accessToken2 = jwtUtil.createAccessToken(hostUser2.getUserId()); + + // then + mockMvc.perform(delete("/users") + .header("Authorization", "Bearer " + accessToken2) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(USER_CANNOT_DELETE_ROOM_HOST.getCode())); + + } + + + private RoomJpaEntity createRoom(BookJpaEntity book, Category category, RoomStatus roomStatus) { + return roomJpaRepository.save(RoomJpaEntity.builder() + .title("방이름") + .description("설명") + .isPublic(true) + .startDate(LocalDate.now().minusDays(1)) + .endDate(LocalDate.now().plusDays(30)) + .recruitCount(3) + .bookJpaEntity(book) + .category(category) + .memberCount(3) // 방장과 참여자 포함 + .roomStatus(roomStatus) + .build()); + } + + private void updateRoomPercentage(RoomParticipantJpaEntity roomParticipant1, RoomParticipantJpaEntity roomParticipant2, + RoomParticipantJpaEntity roomParticipant3,RoomJpaEntity room) { + roomParticipant1.updateCurrentPage(50); // 탈퇴하는 유저의 진행도 50 + roomParticipantJpaRepository.save(roomParticipant1); + + roomParticipant2.updateCurrentPage(30); // 2번째 유저의 진행도 30; + roomParticipantJpaRepository.save(roomParticipant2); + + roomParticipant3.updateCurrentPage(60); // 3번째 유저의 진행도 60 + roomParticipantJpaRepository.save(roomParticipant3); + + room.updateRoomPercentage(46.6); // 방참여자들의 진행도 평균 46.6 --> 유저1이 탈퇴하면 진행도 평균은 45 + roomJpaRepository.save(room); + } +}