diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt index 1fe46b2d..e469cbe3 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -12,22 +12,24 @@ import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime @Service class ManageReceiptUseCase( private val receiptRepository: ReceiptRepository, private val accountRepository: AccountRepository, - private val fileReader: FileReader, private val fileRepository: FileRepository, private val cardinalReader: CardinalReader, private val clubPermissionPolicy: ClubPermissionPolicy, private val fileMapper: FileMapper, + private val clock: Clock, ) { @Transactional fun save( @@ -70,7 +72,7 @@ class ManageReceiptUseCase( account.adjustSpend(Money.of(receipt.amount), Money.of(request.amount)) if (request.files != null) { - fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) + markReceiptFilesDeleted(receiptId) fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId)) } @@ -90,7 +92,20 @@ class ManageReceiptUseCase( receipt.account.cancelSpend(Money.of(receipt.amount)) - fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) + markReceiptFilesDeleted(receiptId) receiptRepository.delete(receipt) } + + /** + * 영수증 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ + private fun markReceiptFilesDeleted(receiptId: Long) { + val now = LocalDateTime.now(clock) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.RECEIPT, + ownerId = receiptId, + deletedAt = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), + ) + } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index cb434657..5ee77cf6 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -17,11 +17,13 @@ import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime @Service class ManagePostUseCase( @@ -30,9 +32,9 @@ class ManagePostUseCase( private val clubMemberPolicy: ClubMemberPolicy, private val clubMemberCardinalReader: ClubMemberCardinalReader, private val fileRepository: FileRepository, - private val fileReader: FileReader, private val fileMapper: FileMapper, private val postMapper: PostMapper, + private val clock: Clock, ) { @Transactional fun save( @@ -153,12 +155,16 @@ class ManagePostUseCase( } } + /** + * 게시글 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ private fun deletePostFiles(postId: Long) { - val files = fileReader.findAll(FileOwnerType.POST, postId) - - if (files.isNotEmpty()) { - fileRepository.deleteAll(files) - fileRepository.flush() - } + val now = LocalDateTime.now(clock) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.POST, + ownerId = postId, + deletedAt = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), + ) } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt index 6418d4ad..d2727ca5 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt @@ -37,6 +37,24 @@ interface PostLikeRepository : @Param("clubId") clubId: Long, ): List + @Query( + """ + SELECT p.id + FROM PostLike pl + JOIN pl.post p + JOIN p.board b + WHERE pl.userId = :userId + AND b.club.id IN :clubIds + AND pl.isActive = true + AND pl.deletedAt IS NULL + ORDER BY p.id ASC + """, + ) + fun findActivePostIdsByUserIdAndClubIdIn( + @Param("userId") userId: Long, + @Param("clubIds") clubIds: List, + ): List + @Query( """ SELECT pl diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt index 4ce48920..da6e9ec1 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -217,4 +217,18 @@ interface PostRepository : fun countActivePostsByBoardIds( @Param("boardIds") boardIds: List, ): List + + @Query( + """ + SELECT p.id + FROM Post p + WHERE p.clubMember.id IN :clubMemberIds + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.id ASC + """, + ) + fun findActiveIdsByClubMemberIdIn( + @Param("clubMemberIds") clubMemberIds: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index e6e5f98c..264c7536 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -22,8 +22,6 @@ import com.weeth.domain.club.domain.service.ClubJoinPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus -import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader @@ -47,7 +45,6 @@ class ManageClubMemberUsecase( private val clubJoinPolicy: ClubJoinPolicy, private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, private val fileRepository: FileRepository, - private val fileAccessUrlPort: FileAccessUrlPort, private val clock: Clock, ) { /** @@ -92,19 +89,11 @@ class ManageClubMemberUsecase( userId: Long, request: UpdateMemberProfileRequest, ) { - val members = clubMemberRepository.findActiveByUserId(userId) + val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId) if (members.isEmpty()) throw ClubMemberNotFoundException() request.profileImage?.let { profileImage -> - val existingFiles = - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - if (existingFiles.isNotEmpty()) { - fileRepository.deleteAll(existingFiles) - } + markFilesDeleted(userId) val file = File.createUploaded( @@ -125,18 +114,10 @@ class ManageClubMemberUsecase( @Transactional fun deleteProfileImage(userId: Long) { - val members = clubMemberRepository.findActiveByUserId(userId) + val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId) if (members.isEmpty()) throw ClubMemberNotFoundException() - val existingFiles = - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - if (existingFiles.isNotEmpty()) { - fileRepository.deleteAll(existingFiles) - } + markFilesDeleted(userId) members.forEach { it.removeProfileImage() } } @@ -186,4 +167,17 @@ class ManageClubMemberUsecase( clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now) member.leave(now) } + + /** + * 프로필 이미지 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ + private fun markFilesDeleted(userId: Long) { + val now = LocalDateTime.now(clock) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + deletedAt = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), + ) + } } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 9c6ace04..60cbdb8f 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -28,12 +28,13 @@ import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime /** * 동아리 관리 유스케이스 @@ -51,6 +52,7 @@ class ManageClubUseCase( private val clubPermissionPolicy: ClubPermissionPolicy, private val fileRepository: FileRepository, private val clubMapper: ClubMapper, + private val clock: Clock, ) { /** * 새로운 동아리를 생성 @@ -235,15 +237,20 @@ class ManageClubUseCase( fileRepository.save(file) } + /** + * 동아리 이미지 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ private fun deleteExistingFiles( ownerType: FileOwnerType, ownerId: Long, ) { - val files = fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, FileStatus.UPLOADED) - - if (files.isNotEmpty()) { - fileRepository.deleteAll(files) - } + val now = LocalDateTime.now(clock) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = ownerType, + ownerId = ownerId, + deletedAt = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), + ) } private fun validatePrimaryContactEmail( diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt index 2e2c34a2..646636bd 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt @@ -3,6 +3,10 @@ package com.weeth.domain.club.domain.service import com.weeth.domain.board.domain.repository.PostLikeRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -19,31 +23,83 @@ import java.time.LocalDateTime class ClubActivityDeletionPolicy( private val postLikeRepository: PostLikeRepository, private val postRepository: PostRepository, + private val commentRepository: CommentRepository, + private val fileRepository: FileRepository, ) { fun markMemberActivitiesDeleted( member: ClubMember, now: LocalDateTime, ) { - val postIds = - postLikeRepository - .findActivePostIdsByUserIdAndClubId( - userId = member.user.id, - clubId = member.club.id, - ).distinct() - .sorted() - - if (postIds.isEmpty()) return - - val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id } - if (postsById.isEmpty()) return - - postLikeRepository - .findAllActiveByUserIdAndPostIds( - userId = member.user.id, - postIds = postsById.keys.toList(), - ).forEach { like -> - if (!like.markDeleted(now)) return@forEach - postsById.getValue(like.post.id).decreaseLikeCount() + markMembersActivitiesDeleted(listOf(member), now) + } + + fun markMembersActivitiesDeleted( + members: List, + now: LocalDateTime, + ) { + if (members.isEmpty()) return + + markMembersFilesDeleted(members, now) + markMembersPostLikesDeleted(members, now) + } + + private fun markMembersFilesDeleted( + members: List, + now: LocalDateTime, + ) { + val memberIds = members.map { it.id }.distinct().sorted() + val postIds = postRepository.findActiveIdsByClubMemberIdIn(memberIds) + val commentIds = commentRepository.findActiveIdsByClubMemberIdIn(memberIds) + + markFilesDeleted(FileOwnerType.POST, postIds, now) + markFilesDeleted(FileOwnerType.COMMENT, commentIds, now) + } + + private fun markFilesDeleted( + ownerType: FileOwnerType, + ownerIds: List, + now: LocalDateTime, + ) { + if (ownerIds.isEmpty()) return + + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + ownerType = ownerType, + ownerIds = ownerIds, + deletedAt = now, + hardDeleteAfter = File.retainedHardDeleteAfter(now), + ) + } + + private fun markMembersPostLikesDeleted( + members: List, + now: LocalDateTime, + ) { + members + .groupBy { it.user.id } + .forEach { (userId, userMembers) -> + val clubIds = userMembers.map { it.club.id }.distinct().sorted() + val postIds = + postLikeRepository + .findActivePostIdsByUserIdAndClubIdIn( + userId = userId, + clubIds = clubIds, + ).distinct() + .sorted() + + if (postIds.isEmpty()) return@forEach + + val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id } + + val likes = + postLikeRepository.findAllActiveByUserIdAndPostIds( + userId = userId, + postIds = postIds, + ) + + for (like in likes) { + if (!like.markDeleted(now)) continue + postsById[like.post.id]?.decreaseLikeCount() + } } } } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index ffdc7170..e1cbc568 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -14,20 +14,22 @@ import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.comment.domain.repository.CommentRepository import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime @Service class ManageCommentUseCase( private val commentRepository: CommentRepository, private val postRepository: PostRepository, // 타 도메인 이므로 Reader 사용 검토 private val clubMemberPolicy: ClubMemberPolicy, - private val fileReader: FileReader, private val fileRepository: FileRepository, private val fileMapper: FileMapper, + private val clock: Clock, ) : PostCommentUsecase { @Transactional override fun savePostComment( @@ -136,12 +138,17 @@ class ManageCommentUseCase( deleteCommentFiles(comment.id) } + /** + * 댓글 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ private fun deleteCommentFiles(commentId: Long) { - val files = fileReader.findAll(FileOwnerType.COMMENT, commentId) - - if (files.isNotEmpty()) { - fileRepository.deleteAll(files) - } + val now = LocalDateTime.now(clock) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.COMMENT, + ownerId = commentId, + deletedAt = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), + ) } private fun ensureOwner( diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt index b511b037..d5de727b 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -20,4 +20,19 @@ interface CommentRepository : override fun findAllByPostId( @Param("postId") postId: Long, ): List + + @Query( + """ + SELECT c.id + FROM Comment c + WHERE c.clubMember.id IN :clubMemberIds + AND c.isDeleted = false + AND c.post.isDeleted = false + AND c.post.board.isDeleted = false + ORDER BY c.id ASC + """, + ) + fun findActiveIdsByClubMemberIdIn( + @Param("clubMemberIds") clubMemberIds: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt index d1fd0a67..0ab35820 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt @@ -14,6 +14,7 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Index import jakarta.persistence.Table +import java.time.LocalDateTime @Entity @Table( @@ -44,8 +45,54 @@ class File( @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) var status: FileStatus = FileStatus.UPLOADED, + isDeleted: Boolean = false, + deletedAt: LocalDateTime? = null, + hardDeleteAfter: LocalDateTime? = null, ) : BaseEntity() { + @Column(nullable = false) + var isDeleted: Boolean = isDeleted + private set + + @Column(name = "deleted_at", nullable = true) + var deletedAt: LocalDateTime? = deletedAt + private set + + @Column(name = "hard_delete_after", nullable = true) + var hardDeleteAfter: LocalDateTime? = hardDeleteAfter + private set + + init { + require(!isDeleted || deletedAt != null) { "삭제된 파일은 deletedAt이 필요합니다." } + require(!isDeleted || hardDeleteAfter != null) { "삭제된 파일은 hardDeleteAfter가 필요합니다." } + } + + fun markDeleted(now: LocalDateTime) { + markDeleted(now, RETENTION_DAYS) + } + + fun markDeletedForImmediateCleanup(now: LocalDateTime) { + markDeleted(now, IMMEDIATE_CLEANUP_DAYS) + } + + private fun markDeleted( + now: LocalDateTime, + retentionDays: Long, + ) { + if (isDeleted) return + + isDeleted = true + deletedAt = now + hardDeleteAfter = now.plusDays(retentionDays) + } + companion object { + private const val RETENTION_DAYS = 30L + private const val IMMEDIATE_CLEANUP_DAYS = 0L + + fun retainedHardDeleteAfter(now: LocalDateTime): LocalDateTime = now.plusDays(RETENTION_DAYS) + + fun immediateHardDeleteAfter(now: LocalDateTime): LocalDateTime = now.plusDays(IMMEDIATE_CLEANUP_DAYS) + fun createUploaded( fileName: String, storageKey: String, diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt index e7b1cadf..16c1015a 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt @@ -4,50 +4,113 @@ import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus 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.time.LocalDateTime interface FileRepository : JpaRepository, FileReader { - fun findAllByOwnerTypeAndOwnerId( + fun findAllByOwnerTypeAndOwnerIdAndIsDeletedFalse( ownerType: FileOwnerType, ownerId: Long, ): List - fun findAllByOwnerTypeAndOwnerIdAndStatus( + fun findAllByOwnerTypeAndOwnerIdAndStatusAndIsDeletedFalse( ownerType: FileOwnerType, ownerId: Long, status: FileStatus, ): List - fun findAllByOwnerTypeAndOwnerIdIn( + fun findAllByOwnerTypeAndOwnerIdInAndIsDeletedFalse( ownerType: FileOwnerType, ownerIds: List, ): List - fun findAllByOwnerTypeAndOwnerIdInAndStatus( + fun findAllByOwnerTypeAndOwnerIdInAndStatusAndIsDeletedFalse( ownerType: FileOwnerType, ownerIds: List, status: FileStatus, ): List - fun existsByOwnerTypeAndOwnerId( + fun existsByOwnerTypeAndOwnerIdAndIsDeletedFalse( ownerType: FileOwnerType, ownerId: Long, ): Boolean - fun existsByOwnerTypeAndOwnerIdAndStatus( + fun existsByOwnerTypeAndOwnerIdAndStatusAndIsDeletedFalse( ownerType: FileOwnerType, ownerId: Long, status: FileStatus, ): Boolean + // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + """ + UPDATE File f + SET f.isDeleted = true, + f.deletedAt = :deletedAt, + f.hardDeleteAfter = :hardDeleteAfter, + f.modifiedAt = :deletedAt + WHERE f.ownerType = :ownerType + AND f.ownerId = :ownerId + AND f.status = com.weeth.domain.file.domain.enums.FileStatus.UPLOADED + AND f.isDeleted = false + """, + ) + fun markActiveDeletedByOwnerTypeAndOwnerId( + @Param("ownerType") ownerType: FileOwnerType, + @Param("ownerId") ownerId: Long, + @Param("deletedAt") deletedAt: LocalDateTime, + @Param("hardDeleteAfter") hardDeleteAfter: LocalDateTime, + ): Int + + // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + """ + UPDATE File f + SET f.isDeleted = true, + f.deletedAt = :deletedAt, + f.hardDeleteAfter = :hardDeleteAfter, + f.modifiedAt = :deletedAt + WHERE f.ownerType = :ownerType + AND f.ownerId IN :ownerIds + AND f.status = com.weeth.domain.file.domain.enums.FileStatus.UPLOADED + AND f.isDeleted = false + """, + ) + fun markActiveDeletedByOwnerTypeAndOwnerIdIn( + @Param("ownerType") ownerType: FileOwnerType, + @Param("ownerIds") ownerIds: List, + @Param("deletedAt") deletedAt: LocalDateTime, + @Param("hardDeleteAfter") hardDeleteAfter: LocalDateTime, + ): Int + + fun findAllActiveByOwnerTypeAndOwnerId( + ownerType: FileOwnerType, + ownerId: Long, + ): List = findAllByOwnerTypeAndOwnerIdAndStatusAndIsDeletedFalse(ownerType, ownerId, FileStatus.UPLOADED) + + fun findAllActiveByOwnerTypeAndOwnerIdIn( + ownerType: FileOwnerType, + ownerIds: List, + ): List = + if (ownerIds.isEmpty()) { + emptyList() + } else { + findAllByOwnerTypeAndOwnerIdInAndStatusAndIsDeletedFalse(ownerType, ownerIds, FileStatus.UPLOADED) + } + override fun findAll( ownerType: FileOwnerType, ownerId: Long, status: FileStatus?, ): List = - status?.let { findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, it) } - ?: findAllByOwnerTypeAndOwnerId(ownerType, ownerId) + status?.let { findAllByOwnerTypeAndOwnerIdAndStatusAndIsDeletedFalse(ownerType, ownerId, it) } + ?: findAllByOwnerTypeAndOwnerIdAndIsDeletedFalse(ownerType, ownerId) override fun findAll( ownerType: FileOwnerType, @@ -57,8 +120,8 @@ interface FileRepository : if (ownerIds.isEmpty()) { return emptyList() } - return status?.let { findAllByOwnerTypeAndOwnerIdInAndStatus(ownerType, ownerIds, it) } - ?: findAllByOwnerTypeAndOwnerIdIn(ownerType, ownerIds) + return status?.let { findAllByOwnerTypeAndOwnerIdInAndStatusAndIsDeletedFalse(ownerType, ownerIds, it) } + ?: findAllByOwnerTypeAndOwnerIdInAndIsDeletedFalse(ownerType, ownerIds) } override fun exists( @@ -66,6 +129,6 @@ interface FileRepository : ownerId: Long, status: FileStatus?, ): Boolean = - status?.let { existsByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, it) } - ?: existsByOwnerTypeAndOwnerId(ownerType, ownerId) + status?.let { existsByOwnerTypeAndOwnerIdAndStatusAndIsDeletedFalse(ownerType, ownerId, it) } + ?: existsByOwnerTypeAndOwnerIdAndIsDeletedFalse(ownerType, ownerId) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt index 898fc3cc..9232b47e 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt @@ -3,6 +3,9 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserHasLeadClubException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase @@ -21,6 +24,7 @@ class LeaveUserUseCase( private val userReader: UserReader, private val clubMemberRepository: ClubMemberRepository, private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, + private val fileRepository: FileRepository, private val jwtManageUseCase: JwtManageUseCase, private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, private val meterRegistry: MeterRegistry, @@ -38,15 +42,31 @@ class LeaveUserUseCase( throw UserHasLeadClubException() } - activeMembers.forEach { member -> - clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now) - member.leave(now) + if (activeMembers.isNotEmpty()) { + clubActivityDeletionPolicy.markMembersActivitiesDeleted(activeMembers, now) } + activeMembers.forEach { it.leave(now) } + markClubMemberProfileFilesDeleted(userId, now) user.leave(now) revokeTokensAfterCommit(userId) } + /** + * 위드 탈퇴는 서비스 전체 탈퇴이므로 user-scope 멤버 프로필 파일을 30일 보관 삭제 예약 + */ + private fun markClubMemberProfileFilesDeleted( + userId: Long, + now: LocalDateTime, + ) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + deletedAt = now, + hardDeleteAfter = File.retainedHardDeleteAfter(now), + ) + } + private fun revokeTokensAfterCommit(userId: Long) { TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt index 9dd2fdb0..1e99bebb 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -16,22 +16,27 @@ import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify +import java.time.Clock +import java.time.Instant import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId import java.util.Optional +import java.util.UUID class ManageReceiptUseCaseTest : DescribeSpec({ + val clock = Clock.fixed(Instant.parse("2026-06-25T03:00:00Z"), ZoneId.of("Asia/Seoul")) val receiptRepository = mockk(relaxUnitFun = true) val accountRepository = mockk() - val fileReader = mockk() val fileRepository = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) val clubPermissionPolicy = mockk(relaxed = true) @@ -40,11 +45,11 @@ class ManageReceiptUseCaseTest : ManageReceiptUseCase( receiptRepository, accountRepository, - fileReader, fileRepository, cardinalReader, clubPermissionPolicy, fileMapper, + clock, ) val userId = 10L @@ -53,7 +58,6 @@ class ManageReceiptUseCaseTest : clearMocks( receiptRepository, accountRepository, - fileReader, fileRepository, cardinalReader, clubPermissionPolicy, @@ -69,6 +73,19 @@ class ManageReceiptUseCaseTest : CardinalTestFixture.createCardinal(cardinalNumber = cardinalNumber) } + fun createReceiptFile( + fileName: String, + receiptId: Long, + ): File = + File.createUploaded( + fileName = fileName, + storageKey = "RECEIPT/2026-02/${UUID.randomUUID()}_$fileName", + fileSize = 100L, + contentType = "image/png", + ownerType = FileOwnerType.RECEIPT, + ownerId = receiptId, + ) + describe("save") { context("파일이 있는 경우") { it("영수증 저장 후 fileRepository.saveAll이 호출된다") { @@ -147,18 +164,24 @@ class ManageReceiptUseCaseTest : 40, listOf(FileSaveRequest("new.png", "TEMP/2026-02/new.png", 100L, "image/png")), ) - val oldFiles = listOf(mockk()) val newFiles = listOf(mockk()) stubExistingCardinal(clubId, request.cardinal) every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns account every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) - every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles every { fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId) } returns newFiles useCase.update(clubId, userId, receiptId, request) - verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.RECEIPT, + receiptId, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -192,36 +215,46 @@ class ManageReceiptUseCaseTest : 40, emptyList(), ) - val oldFiles = listOf(mockk()) - stubExistingCardinal(clubId, request.cardinal) every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns account every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) - every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, receiptId) } returns emptyList() useCase.update(clubId, userId, receiptId, request) - verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.RECEIPT, + receiptId, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(emptyList()) } } } describe("delete") { - it("관련 파일 삭제 후 cancelSpend가 호출되고 영수증이 삭제된다") { + it("관련 파일 삭제 예약 후 cancelSpend가 호출되고 영수증이 삭제된다") { val receiptId = 5L val account = AccountTestFixture.createAccount(currentAmount = 100_000) val clubId = account.club.id val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 10_000, account = account) account.spend(Money.of(receipt.amount)) - val files = listOf(mockk()) - every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) - every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns files useCase.delete(clubId, userId, receiptId) - verify(exactly = 1) { fileRepository.deleteAll(files) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.RECEIPT, + receiptId, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { receiptRepository.delete(receipt) } } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 0fd0ea15..e5004fde 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -25,7 +25,6 @@ import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -37,15 +36,19 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId class ManagePostUseCaseTest : DescribeSpec({ + val clock = Clock.fixed(Instant.parse("2026-06-25T03:00:00Z"), ZoneId.of("Asia/Seoul")) val postRepository = mockk() val boardRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) val clubMemberCardinalReader = mockk() val fileRepository = mockk() - val fileReader = mockk() val fileMapper = mockk() val postMapper = mockk() @@ -56,9 +59,9 @@ class ManagePostUseCaseTest : clubMemberPolicy, clubMemberCardinalReader, fileRepository, - fileReader, fileMapper, postMapper, + clock, ) fun createUploadedPostFile( @@ -81,14 +84,15 @@ class ManagePostUseCaseTest : clubMemberPolicy, clubMemberCardinalReader, fileRepository, - fileReader, fileMapper, postMapper, ) every { postRepository.save(any()) } answers { firstArg() } every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() every { fileRepository.saveAll(any>()) } returns emptyList() - every { fileReader.findAll(any(), any(), any()) } returns emptyList() + every { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) + } returns 0 every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(id = 1L, boardId = 1L) every { fileRepository.delete(any()) } just runs every { fileRepository.flush() } just runs @@ -196,7 +200,9 @@ class ManagePostUseCaseTest : useCase.update(clubId, board.id, 1L, request, 1L) - verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } + verify( + exactly = 0, + ) { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } verify(exactly = 0) { fileRepository.saveAll(any>()) } } @@ -206,7 +212,6 @@ class ManagePostUseCaseTest : val clubId = board.club.id val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - val oldFile = createUploadedPostFile("old.png") val newFiles = listOf(createUploadedPostFile("new.png")) val request = UpdatePostRequest( @@ -224,7 +229,6 @@ class ManagePostUseCaseTest : ) every { postRepository.findActivePostById(1L) } returns post - every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) every { fileRepository.deleteAll(any>()) } just runs every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles every { fileRepository.saveAll(newFiles) } returns newFiles @@ -233,7 +237,15 @@ class ManagePostUseCaseTest : post.title shouldBe "수정" post.content shouldBe "수정" - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.POST, + post.id, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -325,22 +337,28 @@ class ManagePostUseCaseTest : } describe("delete") { - it("삭제 시 첨부 파일을 삭제하고 게시글을 soft delete한다") { + it("삭제 시 첨부 파일을 삭제 예약하고 게시글을 soft delete한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - val oldFile = createUploadedPostFile("old.png") every { postRepository.findActivePostById(1L) } returns post - every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) every { fileRepository.deleteAll(any>()) } just runs useCase.delete(clubId, board.id, 1L, 1L) post.isDeleted shouldBe true - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.POST, + post.id, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 0) { postRepository.delete(any()) } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 3b6e1182..6e64defc 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -155,7 +155,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { commentReader.findAllByPostId(any()) } returns emptyList() every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments - every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files + every { fileReader.findAll(FileOwnerType.POST, post.id, FileStatus.UPLOADED) } returns files every { postLikeRepository.existsByPostAndUserIdAndIsActiveTrueAndDeletedAtIsNull( post, @@ -173,6 +173,7 @@ class GetPostQueryServiceTest : result.id shouldBe 1L result.comments.size shouldBe 1 result.fileUrls.size shouldBe 1 + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, post.id, FileStatus.UPLOADED) } } it("비공개 게시판 게시글은 일반 멤버에게 노출하지 않는다") { @@ -304,7 +305,8 @@ class GetPostQueryServiceTest : every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns listOf(board) every { postRepository.findAllActiveByBoardIds(any(), any()) } returns postSlice - every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.POST, listOf(post.id), FileStatus.UPLOADED) } returns + emptyList() every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response @@ -355,14 +357,15 @@ class GetPostQueryServiceTest : every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice - every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.POST, listOf(post.id), FileStatus.UPLOADED) } returns + emptyList() every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response val result = queryService.findPosts(clubId, userId, 1L, 0, 10) result.content.size shouldBe 1 - verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, any>(), any()) } + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, listOf(post.id), FileStatus.UPLOADED) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 1f168a13..49be6742 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -22,11 +22,9 @@ import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus -import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.file.fixture.FileTestFixture import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture @@ -55,7 +53,6 @@ class ManageClubMemberUseCaseTest : val clubJoinPolicy = mockk() val clubActivityDeletionPolicy = mockk() val fileRepository = mockk() - val fileAccessUrlPort = mockk() val clock = Clock.fixed(Instant.parse("2026-06-08T03:00:00Z"), ZoneId.of("Asia/Seoul")) val useCase = @@ -70,7 +67,6 @@ class ManageClubMemberUseCaseTest : clubJoinPolicy = clubJoinPolicy, clubActivityDeletionPolicy = clubActivityDeletionPolicy, fileRepository = fileRepository, - fileAccessUrlPort = fileAccessUrlPort, clock = clock, ) @@ -86,10 +82,10 @@ class ManageClubMemberUseCaseTest : clubJoinPolicy, clubActivityDeletionPolicy, fileRepository, - fileAccessUrlPort, ) every { clubMemberRepository.save(any()) } answers { firstArg() } every { fileRepository.save(any()) } answers { firstArg() } + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } returns 0 } describe("updateProfile") { @@ -103,32 +99,28 @@ class ManageClubMemberUseCaseTest : ) context("프로필 사진만 변경할 때") { - it("모든 활성 ClubMember의 기존 파일을 삭제하고 새 파일로 URL을 업데이트한다") { + it("모든 활성 ClubMember의 기존 파일을 삭제 예약하고 새 파일로 URL을 업데이트한다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) - val existingFile = - FileTestFixture.createFile( - id = 1L, - fileName = "old.png", - ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, - ownerId = userId, - ) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } returns - Unit + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) + useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) member1.profileImageStorageKey shouldBe profileImageRequest.storageKey member2.profileImageStorageKey shouldBe profileImageRequest.storageKey - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.save(any()) } + verify(exactly = 1) { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } + verify(exactly = 0) { clubMemberRepository.findActiveByUserId(userId) } } } @@ -137,13 +129,16 @@ class ManageClubMemberUseCaseTest : val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) member1.bio shouldBe "안녕하세요!" member2.bio shouldBe "안녕하세요!" - verify(exactly = 0) { fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) } + verify(exactly = 0) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) + } verify(exactly = 0) { fileRepository.save(any()) } } } @@ -153,7 +148,8 @@ class ManageClubMemberUseCaseTest : val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "")) @@ -164,7 +160,7 @@ class ManageClubMemberUseCaseTest : context("활성 동아리 멤버십이 없을 때") { it("ClubMemberNotFoundException을 던진다") { - every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns emptyList() shouldThrow { useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) @@ -177,33 +173,28 @@ class ManageClubMemberUseCaseTest : val userId = 10L context("활성 멤버가 프로필 사진을 삭제할 때") { - it("모든 활성 ClubMember의 파일을 삭제하고 URL을 null로 만든다") { + it("모든 활성 ClubMember의 파일을 삭제 예약하고 URL을 null로 만든다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) member1.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") member2.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") - val existingFile = - FileTestFixture.createFile( - id = 1L, - fileName = "profile.png", - ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, - ownerId = userId, - ) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } returns - Unit + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) useCase.deleteProfileImage(userId) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } + verify(exactly = 1) { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } + verify(exactly = 0) { clubMemberRepository.findActiveByUserId(userId) } member1.profileImageStorageKey shouldBe null member2.profileImageStorageKey shouldBe null } @@ -211,7 +202,7 @@ class ManageClubMemberUseCaseTest : context("활성 동아리 멤버십이 없을 때") { it("ClubMemberNotFoundException을 던진다") { - every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns emptyList() shouldThrow { useCase.deleteProfileImage(userId) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index d2bd81c5..4383a12f 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -38,10 +38,14 @@ import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import java.time.Clock +import java.time.Instant import java.time.LocalDateTime +import java.time.ZoneId class ManageClubUseCaseTest : DescribeSpec({ + val clock = Clock.fixed(Instant.parse("2026-06-25T03:00:00Z"), ZoneId.of("Asia/Seoul")) val clubRepository = mockk() val clubMemberRepository = mockk() val cardinalRepository = mockk() @@ -64,6 +68,7 @@ class ManageClubUseCaseTest : clubPermissionPolicy, fileRepository, clubMapper, + clock, ) val adminMember = com.weeth.domain.club.fixture.ClubMemberTestFixture @@ -91,11 +96,22 @@ class ManageClubUseCaseTest : every { clubRepository.existsBySchoolNameAndName(any(), any()) } returns false every { clubMapper.toCreateResponse(any()) } returns ClubCreateResponse(clubId = "testId", clubName = "테스트") every { fileRepository.save(any()) } answers { firstArg() } - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) - } returns emptyList() + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } returns 0 } + fun createClubFile( + ownerType: FileOwnerType, + fileName: String, + ): File = + File.createUploaded( + fileName = fileName, + storageKey = "${ownerType.name}/2026-02/550e8400-e29b-41d4-a716-446655440000_$fileName", + fileSize = 1024L, + contentType = "image/png", + ownerType = ownerType, + ownerId = 1L, + ) + describe("create") { val user = UserTestFixture.createRegisteredUser() @@ -369,8 +385,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("프로필 이미지를 변경하면 기존 File이 삭제되고 새 File이 생성된다") { - val existingFile = mockk(relaxed = true) + it("프로필 이미지를 변경하면 기존 File이 삭제 예약되고 새 File이 생성된다") { val club = ClubTestFixture.createClub( clubContact = @@ -383,14 +398,6 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_PROFILE, - 1L, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } just Runs useCase.update( 1L, @@ -406,7 +413,15 @@ class ManageClubUseCaseTest : ), ) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_PROFILE, + 1L, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.save(any()) } club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440002_new.png" } @@ -427,7 +442,7 @@ class ManageClubUseCaseTest : useCase.update(1L, 10L, ClubUpdateRequest(name = "새 이름")) verify(exactly = 0) { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } verify(exactly = 0) { fileRepository.save(any()) } } @@ -487,8 +502,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("기존 File 레코드가 삭제된다") { - val existingFile = mockk(relaxed = true) + it("기존 File 레코드가 삭제 예약된다") { val club = ClubTestFixture.createClub( clubContact = @@ -501,18 +515,18 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_PROFILE, - 1L, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } just Runs useCase.deleteProfileImage(1L, 10L) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_PROFILE, + 1L, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } } } @@ -547,8 +561,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe null } - it("기존 File 레코드가 삭제된다") { - val existingFile = mockk(relaxed = true) + it("기존 File 레코드가 삭제 예약된다") { val club = ClubTestFixture.createClub( clubContact = @@ -561,18 +574,18 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_BACKGROUND, - 1L, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } just Runs useCase.deleteBackgroundImage(1L, 10L) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_BACKGROUND, + 1L, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt index bb5fceab..893205c7 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt @@ -7,6 +7,9 @@ import com.weeth.domain.board.fixture.PostLikeTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -21,10 +24,12 @@ class ClubActivityDeletionPolicyTest : DescribeSpec({ val postLikeRepository = mockk() val postRepository = mockk() - val policy = ClubActivityDeletionPolicy(postLikeRepository, postRepository) + val commentRepository = mockk() + val fileRepository = mockk() + val policy = ClubActivityDeletionPolicy(postLikeRepository, postRepository, commentRepository, fileRepository) beforeTest { - clearMocks(postLikeRepository, postRepository) + clearMocks(postLikeRepository, postRepository, commentRepository, fileRepository) } describe("markMemberActivitiesDeleted") { @@ -45,8 +50,11 @@ class ClubActivityDeletionPolicyTest : val now = LocalDateTime.of(2026, 5, 19, 12, 0) every { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } returns listOf(post.id) + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn(any(), any(), any(), any()) } returns 0 every { postRepository.findAllByIdsWithLock(listOf(post.id)) } returns listOf(post) every { postLikeRepository.findAllActiveByUserIdAndPostIds(member.user.id, listOf(post.id)) @@ -58,7 +66,7 @@ class ClubActivityDeletionPolicyTest : like.deletedAt shouldBe now post.likeCount shouldBe 0 verify(exactly = 1) { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } verify(exactly = 1) { postRepository.findAllByIdsWithLock(listOf(post.id)) @@ -77,13 +85,173 @@ class ClubActivityDeletionPolicyTest : ) every { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn(any(), any(), any(), any()) } returns 0 policy.markMemberActivitiesDeleted(member, LocalDateTime.of(2026, 5, 19, 12, 0)) verify(exactly = 0) { postRepository.findAllByIdsWithLock(any()) } verify(exactly = 0) { postLikeRepository.findAllActiveByUserIdAndPostIds(any(), any()) } } + + it("삭제된 게시글에 남은 활성 좋아요도 삭제 마킹한다") { + val club = ClubTestFixture.createClub(id = 1L) + val member = + ClubMemberTestFixture.createActiveMember( + club = club, + user = UserTestFixture.createActiveUser1(id = 10L), + ) + val post = + PostTestFixture.create( + board = BoardTestFixture.create(club = club), + initialLikeCount = 1, + ) + ReflectionTestUtils.setField(post, "id", 100L) + post.markDeleted() + val like = PostLikeTestFixture.createActive(post = post, userId = member.user.id) + val now = LocalDateTime.of(2026, 5, 19, 12, 0) + + every { + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) + } returns listOf(post.id) + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn(any(), any(), any(), any()) } returns 0 + every { postRepository.findAllByIdsWithLock(listOf(post.id)) } returns emptyList() + every { + postLikeRepository.findAllActiveByUserIdAndPostIds(member.user.id, listOf(post.id)) + } returns listOf(like) + + policy.markMemberActivitiesDeleted(member, now) + + like.isActive shouldBe false + like.deletedAt shouldBe now + post.likeCount shouldBe 1 + verify(exactly = 1) { postRepository.findAllByIdsWithLock(listOf(post.id)) } + verify(exactly = 1) { + postLikeRepository.findAllActiveByUserIdAndPostIds(member.user.id, listOf(post.id)) + } + } + + it("작성한 게시글과 댓글의 파일을 30일 보관 삭제 예약한다") { + val club = ClubTestFixture.createClub(id = 1L) + val member = + ClubMemberTestFixture.createActiveMember( + id = 10L, + club = club, + user = UserTestFixture.createActiveUser1(id = 20L), + ) + val now = LocalDateTime.of(2026, 5, 19, 12, 0) + + every { + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) + } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns + listOf(100L, 101L) + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns + listOf(200L) + every { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + listOf(100L, 101L), + now, + now.plusDays(30), + ) + } returns 1 + every { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.COMMENT, + listOf(200L), + now, + now.plusDays(30), + ) + } returns 1 + + policy.markMemberActivitiesDeleted(member, now) + + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + listOf(100L, 101L), + now, + now.plusDays(30), + ) + } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.COMMENT, + listOf(200L), + now, + now.plusDays(30), + ) + } + verify(exactly = 0) { postRepository.findAllByIdsWithLock(any()) } + } + } + + describe("markMembersActivitiesDeleted") { + it("여러 멤버의 게시글과 댓글 파일을 ownerType별 한 번씩 삭제 예약한다") { + val club = ClubTestFixture.createClub(id = 1L) + val user = UserTestFixture.createActiveUser1(id = 20L) + val firstMember = + ClubMemberTestFixture.createActiveMember( + id = 10L, + club = club, + user = user, + ) + val secondMember = + ClubMemberTestFixture.createActiveMember( + id = 11L, + club = ClubTestFixture.createClub(id = 2L), + user = user, + ) + val now = LocalDateTime.of(2026, 5, 19, 12, 0) + + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(100L, 101L) + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(200L) + every { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + listOf(100L, 101L), + now, + now.plusDays(30), + ) + } returns 1 + every { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.COMMENT, + listOf(200L), + now, + now.plusDays(30), + ) + } returns 1 + every { + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(user.id, listOf(1L, 2L)) + } returns emptyList() + + policy.markMembersActivitiesDeleted(listOf(firstMember, secondMember), now) + + verify(exactly = 1) { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } + verify(exactly = 1) { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + listOf(100L, 101L), + now, + now.plusDays(30), + ) + } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.COMMENT, + listOf(200L), + now, + now.plusDays(30), + ) + } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index 8acc5719..94bcf153 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -16,8 +16,6 @@ import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -29,13 +27,17 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId class ManageCommentUseCaseTest : DescribeSpec({ + val clock = Clock.fixed(Instant.parse("2026-06-25T03:00:00Z"), ZoneId.of("Asia/Seoul")) val commentRepository = mockk(relaxUnitFun = true) val postRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) - val fileReader = mockk() val fileRepository = mockk(relaxed = true) val fileMapper = mockk() @@ -44,16 +46,15 @@ class ManageCommentUseCaseTest : commentRepository, postRepository, clubMemberPolicy, - fileReader, fileRepository, fileMapper, + clock, ) beforeTest { - clearMocks(commentRepository, postRepository, clubMemberPolicy, fileReader, fileRepository, fileMapper) + clearMocks(commentRepository, postRepository, clubMemberPolicy, fileRepository, fileMapper) every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() every { commentRepository.save(any()) } answers { firstArg() } - every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() every { commentRepository.delete(any()) } just runs } @@ -99,7 +100,7 @@ class ManageCommentUseCaseTest : } } - it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { + it("files가 있으면 기존 파일은 삭제 예약되고 새 파일이 저장된다") { val owner = UserTestFixture.createActiveUser1(1L) val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) val post = PostTestFixture.create() @@ -117,15 +118,6 @@ class ManageCommentUseCaseTest : ), ), ) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174002_old.png", - fileSize = 200L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = comment.id, - ) val newFile = File.createUploaded( fileName = "new.png", @@ -137,13 +129,20 @@ class ManageCommentUseCaseTest : ) every { commentRepository.findByIdAndPostId(202L, 10L) } returns comment - every { fileReader.findAll(FileOwnerType.COMMENT, 202L, any()) } returns listOf(oldFile) every { fileMapper.toFileList(dto.files, FileOwnerType.COMMENT, 202L) } returns listOf(newFile) useCase.updatePostComment(dto, postId = 10L, commentId = 202L, userId = 1L) comment.content shouldBe "new content" - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.COMMENT, + comment.id, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify { fileRepository.saveAll(listOf(newFile)) } } } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index c2abfcc7..fae469b0 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -8,6 +8,7 @@ import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.fixture.UserTestFixture @@ -59,14 +60,14 @@ class GetCommentQueryServiceTest : val comment = CommentTestFixture.createPostComment(id = 1L, post = post, clubMember = member) val response = stubResponse(1L) - every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), FileStatus.UPLOADED) } returns emptyList() every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response val result = service.toCommentTreeResponses(listOf(comment)) result.size shouldBe 1 result[0].id shouldBe 1L - verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } + verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), FileStatus.UPLOADED) } } it("부모-자식 구조를 트리로 조립한다") { @@ -81,7 +82,8 @@ class GetCommentQueryServiceTest : val childResponse = stubResponse(11L) val parentResponse = stubResponse(10L, children = listOf(childResponse)) - every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), FileStatus.UPLOADED) } returns + emptyList() every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse diff --git a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt index cdcb071c..6180ed15 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt @@ -6,6 +6,7 @@ import com.weeth.domain.file.domain.enums.FileStatus import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import java.time.LocalDateTime class FileTest : DescribeSpec({ @@ -117,4 +118,67 @@ class FileTest : } } } + + describe("markDeleted") { + it("최초 호출 시 삭제 예약 메타데이터를 설정한다") { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val file = + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + + file.markDeleted(now) + + file.isDeleted shouldBe true + file.deletedAt shouldBe now + file.hardDeleteAfter shouldBe now.plusDays(30) + } + + it("중복 호출 시 기존 삭제 예약 메타데이터를 유지한다") { + val firstDeletedAt = LocalDateTime.of(2026, 6, 25, 12, 0) + val secondDeletedAt = firstDeletedAt.plusDays(1) + val file = + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + + file.markDeleted(firstDeletedAt) + file.markDeleted(secondDeletedAt) + + file.isDeleted shouldBe true + file.deletedAt shouldBe firstDeletedAt + file.hardDeleteAfter shouldBe firstDeletedAt.plusDays(30) + } + } + + describe("markDeletedForImmediateCleanup") { + it("즉시 정리 대상 파일은 hardDeleteAfter를 현재 시각으로 설정한다") { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val file = + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + + file.markDeletedForImmediateCleanup(now) + + file.isDeleted shouldBe true + file.deletedAt shouldBe now + file.hardDeleteAfter shouldBe now + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index d4096a6e..75f60fc2 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -4,6 +4,7 @@ import com.weeth.config.TestContainersConfig import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue @@ -15,6 +16,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import org.springframework.jdbc.core.JdbcTemplate import org.springframework.test.util.ReflectionTestUtils +import java.time.LocalDateTime import java.util.UUID @DataJpaTest @@ -24,6 +26,12 @@ class FileRepositoryTest( private val fileRepository: FileRepository, private val jdbcTemplate: JdbcTemplate, ) : DescribeSpec({ + fun row(fileId: Long): Map = + jdbcTemplate.queryForMap( + "SELECT is_deleted, deleted_at, hard_delete_after, modified_at FROM `file` WHERE id = ?", + fileId, + ) + describe("save") { it("파일 정보를 저장하고 조회한다") { val saved = @@ -46,12 +54,69 @@ class FileRepositoryTest( } describe("findAll/exists") { - it("ownerType + ownerId + status 조건에 맞는 데이터만 조회한다") { + it("기본 단건 owner 조회는 업로드 상태이고 삭제 예약되지 않은 파일만 반환한다") { + fileRepository.save(createTestFile("target.png", FileOwnerType.COMMENT, 77L, FileStatus.UPLOADED)) + fileRepository.save( + createTestFile("status-deleted.png", FileOwnerType.COMMENT, 77L, FileStatus.DELETED), + ) + fileRepository.save( + createTestFile( + fileName = "soft-deleted.png", + ownerType = FileOwnerType.COMMENT, + ownerId = 77L, + status = FileStatus.UPLOADED, + isDeleted = true, + ), + ) + + val files = fileRepository.findAll(FileOwnerType.COMMENT, 77L) + + files.map { it.fileName } shouldContainExactly listOf("target.png") + } + + it("기본 ownerId 목록 조회는 업로드 상태이고 삭제 예약되지 않은 파일만 반환한다") { + fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("status-deleted.png", FileOwnerType.POST, 78L, FileStatus.DELETED)) + fileRepository.save( + createTestFile( + fileName = "soft-deleted.png", + ownerType = FileOwnerType.POST, + ownerId = 78L, + status = FileStatus.UPLOADED, + isDeleted = true, + ), + ) + + val files = fileRepository.findAll(FileOwnerType.POST, listOf(77L, 78L)) + + files.map { it.fileName }.sorted() shouldContainExactly listOf("target-1.png", "target-2.png") + } + + it("ownerType + ownerId + status 조건에 맞고 삭제 예약되지 않은 데이터만 조회한다") { fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) fileRepository.save(createTestFile("deleted.png", FileOwnerType.POST, 77L, FileStatus.DELETED)) + fileRepository.save( + createTestFile( + fileName = "soft-deleted.png", + ownerType = FileOwnerType.POST, + ownerId = 77L, + status = FileStatus.UPLOADED, + isDeleted = true, + ), + ) fileRepository.save(createTestFile("other-owner.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) fileRepository.save(createTestFile("other-type.png", FileOwnerType.RECEIPT, 77L, FileStatus.UPLOADED)) + fileRepository.save( + createTestFile( + fileName = "only-soft-deleted.png", + ownerType = FileOwnerType.POST, + ownerId = 99L, + status = FileStatus.UPLOADED, + isDeleted = true, + ), + ) val uploaded = fileRepository.findAll(FileOwnerType.POST, 77L, FileStatus.UPLOADED) val allStatus = fileRepository.findAll(FileOwnerType.POST, 77L, null) @@ -64,6 +129,115 @@ class FileRepositoryTest( fileRepository.exists(FileOwnerType.POST, 77L, FileStatus.DELETED).shouldBeTrue() fileRepository.exists(FileOwnerType.POST, 99L, FileStatus.UPLOADED).shouldBeFalse() } + + it("ownerId 목록 조회에서도 삭제 예약 파일은 제외한다") { + fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + fileRepository.save( + createTestFile( + fileName = "soft-deleted.png", + ownerType = FileOwnerType.POST, + ownerId = 78L, + status = FileStatus.UPLOADED, + isDeleted = true, + ), + ) + + val files = fileRepository.findAll(FileOwnerType.POST, listOf(77L, 78L), FileStatus.UPLOADED) + + files.map { it.fileName }.sorted() shouldContainExactly listOf("target-1.png", "target-2.png") + } + } + + describe("bulk mark deleted") { + it("단건 owner의 활성 파일만 삭제 예약한다") { + val deletedAt = LocalDateTime.of(2026, 6, 25, 12, 0) + val hardDeleteAfter = deletedAt + val target = + fileRepository.save(createTestFile("target.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + val statusDeleted = + fileRepository.save( + createTestFile("status-deleted.png", FileOwnerType.POST, 77L, FileStatus.DELETED), + ) + val alreadyDeleted = + fileRepository.save( + createTestFile( + fileName = "already-deleted.png", + ownerType = FileOwnerType.POST, + ownerId = 77L, + status = FileStatus.UPLOADED, + isDeleted = true, + ), + ) + val otherOwner = + fileRepository.save(createTestFile("other-owner.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + + val updatedCount = + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.POST, + ownerId = 77L, + deletedAt = deletedAt, + hardDeleteAfter = hardDeleteAfter, + ) + + updatedCount shouldBe 1 + row(target.id).booleanBy("is_deleted").shouldBeTrue() + row(target.id).localDateTimeBy("deleted_at") shouldBe deletedAt + row(target.id).localDateTimeBy("hard_delete_after") shouldBe hardDeleteAfter + row(target.id).localDateTimeBy("modified_at") shouldBe deletedAt + row(statusDeleted.id).booleanBy("is_deleted").shouldBeFalse() + row(alreadyDeleted.id).localDateTimeBy("deleted_at") shouldBe LocalDateTime.of(2026, 6, 25, 12, 0) + row(otherOwner.id).booleanBy("is_deleted").shouldBeFalse() + } + + it("단건 bulk 삭제 후 같은 트랜잭션의 재조회에서도 갱신 상태를 반환한다") { + val deletedAt = LocalDateTime.of(2026, 6, 25, 12, 0) + val target = + fileRepository.save(createTestFile("target.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.POST, + ownerId = 77L, + deletedAt = deletedAt, + hardDeleteAfter = deletedAt.plusDays(30), + ) + + fileRepository + .findById(target.id) + .orElseThrow() + .isDeleted + .shouldBeTrue() + } + + it("ownerId 목록의 활성 파일을 한 번에 삭제 예약한다") { + val deletedAt = LocalDateTime.of(2026, 6, 25, 12, 0) + val hardDeleteAfter = deletedAt.plusDays(30) + val first = + fileRepository.save(createTestFile("first.png", FileOwnerType.COMMENT, 77L, FileStatus.UPLOADED)) + val second = + fileRepository.save(createTestFile("second.png", FileOwnerType.COMMENT, 78L, FileStatus.UPLOADED)) + val otherOwner = + fileRepository.save( + createTestFile("other-owner.png", FileOwnerType.COMMENT, 79L, FileStatus.UPLOADED), + ) + + val updatedCount = + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + ownerType = FileOwnerType.COMMENT, + ownerIds = listOf(77L, 78L), + deletedAt = deletedAt, + hardDeleteAfter = hardDeleteAfter, + ) + + updatedCount shouldBe 2 + row(first.id).booleanBy("is_deleted").shouldBeTrue() + row(first.id).localDateTimeBy("hard_delete_after") shouldBe hardDeleteAfter + row(first.id).localDateTimeBy("modified_at") shouldBe deletedAt + row(second.id).booleanBy("is_deleted").shouldBeTrue() + row(second.id).localDateTimeBy("hard_delete_after") shouldBe hardDeleteAfter + row(second.id).localDateTimeBy("modified_at") shouldBe deletedAt + row(otherOwner.id).booleanBy("is_deleted").shouldBeFalse() + } } describe("index usage") { @@ -85,6 +259,14 @@ class FileRepositoryTest( selectedKey shouldBe "idx_file_owner_type_owner_id" } } + + describe("jdbc row helpers") { + it("지원하지 않는 boolean 값은 실패시킨다") { + shouldThrow { + mapOf("is_deleted" to "Y").booleanBy("is_deleted") + } + } + } }) private fun createTestFile( @@ -92,6 +274,7 @@ private fun createTestFile( ownerType: FileOwnerType, ownerId: Long, status: FileStatus, + isDeleted: Boolean = false, ): File = File .createUploaded( @@ -105,6 +288,9 @@ private fun createTestFile( if (status == FileStatus.DELETED) { ReflectionTestUtils.setField(it, "status", FileStatus.DELETED) } + if (isDeleted) { + it.markDeleted(LocalDateTime.of(2026, 6, 25, 12, 0)) + } } private fun Map.valueBy(key: String): String = @@ -113,3 +299,17 @@ private fun Map.valueBy(key: String): String = it.key.equals(key, ignoreCase = true) }.value .toString() + +private fun Map.booleanBy(key: String): Boolean = + when (val value = entries.first { it.key.equals(key, ignoreCase = true) }.value) { + is Boolean -> value + is Number -> value.toInt() != 0 + else -> error("Unsupported Boolean value: $value") + } + +private fun Map.localDateTimeBy(key: String): LocalDateTime = + when (val value = entries.first { it.key.equals(key, ignoreCase = true) }.value) { + is java.sql.Timestamp -> value.toLocalDateTime() + is LocalDateTime -> value + else -> error("Unsupported LocalDateTime value: $value") + } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt index c96783c5..d24ccceb 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt @@ -5,6 +5,8 @@ import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserHasLeadClubException import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader @@ -31,6 +33,7 @@ class LeaveUserUseCaseTest : val userReader = mockk() val clubMemberRepository = mockk() val clubActivityDeletionPolicy = mockk() + val fileRepository = mockk() val jwtManageUseCase = mockk() val accessTokenBlacklistStore = mockk() val meterRegistry = SimpleMeterRegistry() @@ -40,6 +43,7 @@ class LeaveUserUseCaseTest : userReader = userReader, clubMemberRepository = clubMemberRepository, clubActivityDeletionPolicy = clubActivityDeletionPolicy, + fileRepository = fileRepository, jwtManageUseCase = jwtManageUseCase, accessTokenBlacklistStore = accessTokenBlacklistStore, meterRegistry = meterRegistry, @@ -51,9 +55,11 @@ class LeaveUserUseCaseTest : userReader, clubMemberRepository, clubActivityDeletionPolicy, + fileRepository, jwtManageUseCase, accessTokenBlacklistStore, ) + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } returns 0 if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.clearSynchronization() } @@ -173,7 +179,7 @@ class LeaveUserUseCaseTest : val now = LocalDateTime.now(clock) every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(userMember, adminMember) - justRun { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + justRun { clubActivityDeletionPolicy.markMembersActivitiesDeleted(any(), any()) } justRun { jwtManageUseCase.deleteRefreshToken(1L) } justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() @@ -185,8 +191,31 @@ class LeaveUserUseCaseTest : adminMember.memberStatus shouldBe MemberStatus.LEFT adminMember.leftAt shouldBe now user.status shouldBe Status.LEFT - verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(userMember, now) } - verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(adminMember, now) } + verify(exactly = 1) { + clubActivityDeletionPolicy.markMembersActivitiesDeleted(listOf(userMember, adminMember), now) + } + verify(exactly = 0) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + } + + it("위드 탈퇴 시 멤버 프로필 파일을 30일 보관 삭제 예약한다") { + val user = UserTestFixture.createRegisteredUser(1L) + val now = LocalDateTime.now(clock) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + justRun { accessTokenBlacklistStore.blacklist(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_MEMBER_PROFILE, + 1L, + now, + now.plusDays(30), + ) + } } it("ACTIVE LEAD 멤버십이 있으면 탈퇴를 차단하고 상태를 변경하지 않는다") { @@ -207,6 +236,9 @@ class LeaveUserUseCaseTest : user.status shouldBe Status.ACTIVE leadMember.memberStatus shouldBe MemberStatus.ACTIVE verify(exactly = 0) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + verify( + exactly = 0, + ) { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } } }