From c86c15c68fe0e672cc10723bc315b9a86ebef903 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 14:57:26 +0900 Subject: [PATCH 01/15] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weeth/domain/file/domain/entity/File.kt | 31 +++++++++++++ .../domain/file/domain/entity/FileTest.kt | 43 +++++++++++++++++++ 2 files changed, 74 insertions(+) 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..0a1aa223 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,38 @@ 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) { + if (isDeleted) return + + isDeleted = true + deletedAt = now + hardDeleteAfter = now.plusDays(RETENTION_DAYS) + } + companion object { + private const val RETENTION_DAYS = 30L + fun createUploaded( fileName: String, storageKey: String, 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..99219f94 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,46 @@ 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) + } + } }) From 73a9f8a9aec1f2c0c8302c7b44e6a4bac45fd39b Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 15:03:43 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=EC=99=B8=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/domain/repository/FileRepository.kt | 60 +++++++++++++++++-- .../domain/repository/FileRepositoryTest.kt | 43 ++++++++++++- 2 files changed, 96 insertions(+), 7 deletions(-) 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..d32aed60 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 @@ -19,6 +19,17 @@ interface FileRepository : status: FileStatus, ): List + fun findAllByOwnerTypeAndOwnerIdAndIsDeletedFalse( + ownerType: FileOwnerType, + ownerId: Long, + ): List + + fun findAllByOwnerTypeAndOwnerIdAndStatusAndIsDeletedFalse( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, + ): List + fun findAllByOwnerTypeAndOwnerIdIn( ownerType: FileOwnerType, ownerIds: List, @@ -30,6 +41,17 @@ interface FileRepository : status: FileStatus, ): List + fun findAllByOwnerTypeAndOwnerIdInAndIsDeletedFalse( + ownerType: FileOwnerType, + ownerIds: List, + ): List + + fun findAllByOwnerTypeAndOwnerIdInAndStatusAndIsDeletedFalse( + ownerType: FileOwnerType, + ownerIds: List, + status: FileStatus, + ): List + fun existsByOwnerTypeAndOwnerId( ownerType: FileOwnerType, ownerId: Long, @@ -41,13 +63,39 @@ interface FileRepository : status: FileStatus, ): Boolean + fun existsByOwnerTypeAndOwnerIdAndIsDeletedFalse( + ownerType: FileOwnerType, + ownerId: Long, + ): Boolean + + fun existsByOwnerTypeAndOwnerIdAndStatusAndIsDeletedFalse( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, + ): Boolean + + 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 +105,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 +114,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/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index d4096a6e..34ccd3a5 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 @@ -15,6 +15,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 @@ -46,12 +47,30 @@ class FileRepositoryTest( } describe("findAll/exists") { - it("ownerType + ownerId + status 조건에 맞는 데이터만 조회한다") { + 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 +83,24 @@ 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("index usage") { @@ -92,6 +129,7 @@ private fun createTestFile( ownerType: FileOwnerType, ownerId: Long, status: FileStatus, + isDeleted: Boolean = false, ): File = File .createUploaded( @@ -105,6 +143,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 = From e85c62bf7252cf25af0a76e1ba355b0a5e4544d2 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 15:35:40 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80/?= =?UTF-8?q?=EB=8C=93=EA=B8=80/=EC=98=81=EC=88=98=EC=A6=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20=EC=98=88=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManageReceiptUseCase.kt | 14 +++++- .../usecase/command/ManagePostUseCase.kt | 9 ++-- .../usecase/command/ManageCommentUseCase.kt | 8 ++-- .../command/ManageReceiptUseCaseTest.kt | 47 ++++++++++++++++--- .../usecase/command/ManagePostUseCaseTest.kt | 18 +++++-- .../command/ManageCommentUseCaseTest.kt | 13 ++++- 6 files changed, 88 insertions(+), 21 deletions(-) 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..6d8a1bdf 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 @@ -18,6 +18,8 @@ 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( @@ -28,6 +30,7 @@ class ManageReceiptUseCase( private val cardinalReader: CardinalReader, private val clubPermissionPolicy: ClubPermissionPolicy, private val fileMapper: FileMapper, + private val clock: Clock, ) { @Transactional fun save( @@ -70,7 +73,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 +93,14 @@ 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) + fileReader + .findAll(FileOwnerType.RECEIPT, receiptId, null) + .forEach { it.markDeleted(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..658b9100 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 @@ -22,6 +22,8 @@ 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( @@ -33,6 +35,7 @@ class ManagePostUseCase( private val fileReader: FileReader, private val fileMapper: FileMapper, private val postMapper: PostMapper, + private val clock: Clock, ) { @Transactional fun save( @@ -156,9 +159,7 @@ 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) + files.forEach { it.markDeleted(now) } } } 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..02bb9206 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 @@ -19,6 +19,8 @@ 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( @@ -28,6 +30,7 @@ class ManageCommentUseCase( private val fileReader: FileReader, private val fileRepository: FileRepository, private val fileMapper: FileMapper, + private val clock: Clock, ) : PostCommentUsecase { @Transactional override fun savePostComment( @@ -139,9 +142,8 @@ class ManageCommentUseCase( private fun deleteCommentFiles(commentId: Long) { val files = fileReader.findAll(FileOwnerType.COMMENT, commentId) - if (files.isNotEmpty()) { - fileRepository.deleteAll(files) - } + val now = LocalDateTime.now(clock) + files.forEach { it.markDeleted(now) } } private fun ensureOwner( 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..9888a64f 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 @@ -20,15 +20,22 @@ 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() @@ -45,6 +52,7 @@ class ManageReceiptUseCaseTest : cardinalReader, clubPermissionPolicy, fileMapper, + clock, ) val userId = 10L @@ -69,6 +77,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,7 +168,8 @@ class ManageReceiptUseCaseTest : 40, listOf(FileSaveRequest("new.png", "TEMP/2026-02/new.png", 100L, "image/png")), ) - val oldFiles = listOf(mockk()) + val oldFile = createReceiptFile("old.png", receiptId) + val oldFiles = listOf(oldFile) val newFiles = listOf(mockk()) stubExistingCardinal(clubId, request.cardinal) @@ -158,7 +180,10 @@ class ManageReceiptUseCaseTest : useCase.update(clubId, userId, receiptId, request) - verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + oldFile.isDeleted shouldBe true + oldFile.deletedAt shouldBe LocalDateTime.now(clock) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -192,7 +217,8 @@ class ManageReceiptUseCaseTest : 40, emptyList(), ) - val oldFiles = listOf(mockk()) + val oldFile = createReceiptFile("old.png", receiptId) + val oldFiles = listOf(oldFile) stubExistingCardinal(clubId, request.cardinal) every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns account @@ -202,26 +228,33 @@ class ManageReceiptUseCaseTest : useCase.update(clubId, userId, receiptId, request) - verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + oldFile.isDeleted shouldBe true + oldFile.deletedAt shouldBe LocalDateTime.now(clock) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + 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()) + val oldFile = createReceiptFile("old.png", receiptId) + val files = listOf(oldFile) 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) } + oldFile.isDeleted shouldBe true + oldFile.deletedAt shouldBe LocalDateTime.now(clock) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + 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..8a4902f7 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 @@ -37,9 +37,14 @@ 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) @@ -59,6 +64,7 @@ class ManagePostUseCaseTest : fileReader, fileMapper, postMapper, + clock, ) fun createUploadedPostFile( @@ -233,7 +239,10 @@ class ManagePostUseCaseTest : post.title shouldBe "수정" post.content shouldBe "수정" - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + oldFile.isDeleted shouldBe true + oldFile.deletedAt shouldBe LocalDateTime.now(clock) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -325,7 +334,7 @@ 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 @@ -340,7 +349,10 @@ class ManagePostUseCaseTest : useCase.delete(clubId, board.id, 1L, 1L) post.isDeleted shouldBe true - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + oldFile.isDeleted shouldBe true + oldFile.deletedAt shouldBe LocalDateTime.now(clock) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 0) { postRepository.delete(any()) } } 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..e1093225 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 @@ -29,9 +29,14 @@ 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) @@ -47,6 +52,7 @@ class ManageCommentUseCaseTest : fileReader, fileRepository, fileMapper, + clock, ) beforeTest { @@ -99,7 +105,7 @@ class ManageCommentUseCaseTest : } } - it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { + it("files가 있으면 기존 파일은 삭제 예약되고 새 파일이 저장된다") { val owner = UserTestFixture.createActiveUser1(1L) val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) val post = PostTestFixture.create() @@ -143,7 +149,10 @@ class ManageCommentUseCaseTest : useCase.updatePostComment(dto, postId = 10L, commentId = 202L, userId = 1L) comment.content shouldBe "new content" - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + oldFile.isDeleted shouldBe true + oldFile.deletedAt shouldBe LocalDateTime.now(clock) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify { fileRepository.saveAll(listOf(newFile)) } } } From f8a2d13264544807262741b2b458a8653e25c841 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 22:16:39 +0900 Subject: [PATCH 04/15] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=98=88=EC=95=BD=EA=B3=BC=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=95=EB=A6=AC=20=EB=8C=80=EC=83=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManageReceiptUseCase.kt | 8 ++- .../usecase/command/ManagePostUseCase.kt | 5 +- .../command/ManageClubMemberUsecase.kt | 25 ++++---- .../usecase/command/ManageClubUseCase.kt | 15 +++-- .../usecase/command/ManageCommentUseCase.kt | 5 +- .../weeth/domain/file/domain/entity/File.kt | 14 ++++- .../command/ManageReceiptUseCaseTest.kt | 6 +- .../usecase/command/ManagePostUseCaseTest.kt | 4 +- .../command/ManageClubMemberUseCaseTest.kt | 32 +++++----- .../usecase/command/ManageClubUseCaseTest.kt | 61 +++++++++++++------ .../command/ManageCommentUseCaseTest.kt | 2 +- .../domain/file/domain/entity/FileTest.kt | 21 +++++++ 12 files changed, 129 insertions(+), 69 deletions(-) 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 6d8a1bdf..119dad1c 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 @@ -97,10 +97,12 @@ class ManageReceiptUseCase( receiptRepository.delete(receipt) } + /** + * 영수증 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ private fun markReceiptFilesDeleted(receiptId: Long) { val now = LocalDateTime.now(clock) - fileReader - .findAll(FileOwnerType.RECEIPT, receiptId, null) - .forEach { it.markDeleted(now) } + val files = fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) + files.forEach { it.markDeletedForImmediateCleanup(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 658b9100..6914cc74 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 @@ -156,10 +156,13 @@ class ManagePostUseCase( } } + /** + * 게시글 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ private fun deletePostFiles(postId: Long) { val files = fileReader.findAll(FileOwnerType.POST, postId) val now = LocalDateTime.now(clock) - files.forEach { it.markDeleted(now) } + files.forEach { it.markDeletedForImmediateCleanup(now) } } } 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..0bb9b2f6 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, ) { /** @@ -97,14 +94,11 @@ class ManageClubMemberUsecase( request.profileImage?.let { profileImage -> val existingFiles = - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + fileRepository.findAllActiveByOwnerTypeAndOwnerId( FileOwnerType.CLUB_MEMBER_PROFILE, userId, - FileStatus.UPLOADED, ) - if (existingFiles.isNotEmpty()) { - fileRepository.deleteAll(existingFiles) - } + markFilesDeleted(existingFiles) val file = File.createUploaded( @@ -129,14 +123,11 @@ class ManageClubMemberUsecase( if (members.isEmpty()) throw ClubMemberNotFoundException() val existingFiles = - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + fileRepository.findAllActiveByOwnerTypeAndOwnerId( FileOwnerType.CLUB_MEMBER_PROFILE, userId, - FileStatus.UPLOADED, ) - if (existingFiles.isNotEmpty()) { - fileRepository.deleteAll(existingFiles) - } + markFilesDeleted(existingFiles) members.forEach { it.removeProfileImage() } } @@ -186,4 +177,12 @@ class ManageClubMemberUsecase( clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now) member.leave(now) } + + /** + * 프로필 이미지 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ + private fun markFilesDeleted(files: List) { + val now = LocalDateTime.now(clock) + files.forEach { it.markDeletedForImmediateCleanup(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..53000336 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,16 @@ 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) + val files = fileRepository.findAllActiveByOwnerTypeAndOwnerId(ownerType, ownerId) + files.forEach { it.markDeletedForImmediateCleanup(now) } } private fun validatePrimaryContactEmail( 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 02bb9206..79bfbc5f 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 @@ -139,11 +139,14 @@ class ManageCommentUseCase( deleteCommentFiles(comment.id) } + /** + * 댓글 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 + */ private fun deleteCommentFiles(commentId: Long) { val files = fileReader.findAll(FileOwnerType.COMMENT, commentId) val now = LocalDateTime.now(clock) - files.forEach { it.markDeleted(now) } + files.forEach { it.markDeletedForImmediateCleanup(now) } } private fun ensureOwner( 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 0a1aa223..13b28bb2 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 @@ -67,15 +67,27 @@ class File( } 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(RETENTION_DAYS) + hardDeleteAfter = now.plusDays(retentionDays) } companion object { private const val RETENTION_DAYS = 30L + private const val IMMEDIATE_CLEANUP_DAYS = 0L fun createUploaded( fileName: String, 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 9888a64f..fa493896 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 @@ -182,7 +182,7 @@ class ManageReceiptUseCaseTest : oldFile.isDeleted shouldBe true oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -230,7 +230,7 @@ class ManageReceiptUseCaseTest : oldFile.isDeleted shouldBe true oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(emptyList()) } } @@ -253,7 +253,7 @@ class ManageReceiptUseCaseTest : oldFile.isDeleted shouldBe true oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + oldFile.hardDeleteAfter shouldBe 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 8a4902f7..11313bd6 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 @@ -241,7 +241,7 @@ class ManagePostUseCaseTest : post.content shouldBe "수정" oldFile.isDeleted shouldBe true oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -351,7 +351,7 @@ class ManagePostUseCaseTest : post.isDeleted shouldBe true oldFile.isDeleted shouldBe true oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 0) { postRepository.delete(any()) } } 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..f9fb10a1 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,9 +22,8 @@ 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 @@ -55,7 +54,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 +68,6 @@ class ManageClubMemberUseCaseTest : clubJoinPolicy = clubJoinPolicy, clubActivityDeletionPolicy = clubActivityDeletionPolicy, fileRepository = fileRepository, - fileAccessUrlPort = fileAccessUrlPort, clock = clock, ) @@ -86,7 +83,6 @@ class ManageClubMemberUseCaseTest : clubJoinPolicy, clubActivityDeletionPolicy, fileRepository, - fileAccessUrlPort, ) every { clubMemberRepository.save(any()) } answers { firstArg() } every { fileRepository.save(any()) } answers { firstArg() } @@ -103,7 +99,7 @@ class ManageClubMemberUseCaseTest : ) context("프로필 사진만 변경할 때") { - it("모든 활성 ClubMember의 기존 파일을 삭제하고 새 파일로 URL을 업데이트한다") { + it("모든 활성 ClubMember의 기존 파일을 삭제 예약하고 새 파일로 URL을 업데이트한다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) val existingFile = @@ -115,19 +111,19 @@ class ManageClubMemberUseCaseTest : ) every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + fileRepository.findAllActiveByOwnerTypeAndOwnerId( FileOwnerType.CLUB_MEMBER_PROFILE, userId, - FileStatus.UPLOADED, ) } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } returns - Unit useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) member1.profileImageStorageKey shouldBe profileImageRequest.storageKey member2.profileImageStorageKey shouldBe profileImageRequest.storageKey - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + existingFile.isDeleted shouldBe true + existingFile.deletedAt shouldBe LocalDateTime.now(clock) + existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify(exactly = 1) { fileRepository.save(any()) } } } @@ -143,7 +139,7 @@ class ManageClubMemberUseCaseTest : member1.bio shouldBe "안녕하세요!" member2.bio shouldBe "안녕하세요!" - verify(exactly = 0) { fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) } + verify(exactly = 0) { fileRepository.findAllActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { fileRepository.save(any()) } } } @@ -177,7 +173,7 @@ 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") @@ -192,18 +188,18 @@ class ManageClubMemberUseCaseTest : every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + fileRepository.findAllActiveByOwnerTypeAndOwnerId( FileOwnerType.CLUB_MEMBER_PROFILE, userId, - FileStatus.UPLOADED, ) } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } returns - Unit useCase.deleteProfileImage(userId) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + existingFile.isDeleted shouldBe true + existingFile.deletedAt shouldBe LocalDateTime.now(clock) + existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } member1.profileImageStorageKey shouldBe null member2.profileImageStorageKey shouldBe null } 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..692a287f 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 @@ -92,10 +97,23 @@ class ManageClubUseCaseTest : every { clubMapper.toCreateResponse(any()) } returns ClubCreateResponse(clubId = "testId", clubName = "테스트") every { fileRepository.save(any()) } answers { firstArg() } every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) + fileRepository.findAllActiveByOwnerTypeAndOwnerId(any(), any()) } returns emptyList() } + 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 +387,8 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("프로필 이미지를 변경하면 기존 File이 삭제되고 새 File이 생성된다") { - val existingFile = mockk(relaxed = true) + it("프로필 이미지를 변경하면 기존 File이 삭제 예약되고 새 File이 생성된다") { + val existingFile = createClubFile(FileOwnerType.CLUB_PROFILE, "old_profile.png") val club = ClubTestFixture.createClub( clubContact = @@ -384,13 +402,11 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + fileRepository.findAllActiveByOwnerTypeAndOwnerId( FileOwnerType.CLUB_PROFILE, 1L, - FileStatus.UPLOADED, ) } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } just Runs useCase.update( 1L, @@ -406,7 +422,10 @@ class ManageClubUseCaseTest : ), ) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + existingFile.isDeleted shouldBe true + existingFile.deletedAt shouldBe LocalDateTime.now(clock) + existingFile.hardDeleteAfter shouldBe 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 +446,7 @@ class ManageClubUseCaseTest : useCase.update(1L, 10L, ClubUpdateRequest(name = "새 이름")) verify(exactly = 0) { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) + fileRepository.findAllActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { fileRepository.save(any()) } } @@ -487,8 +506,8 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("기존 File 레코드가 삭제된다") { - val existingFile = mockk(relaxed = true) + it("기존 File 레코드가 삭제 예약된다") { + val existingFile = createClubFile(FileOwnerType.CLUB_PROFILE, "old_profile.png") val club = ClubTestFixture.createClub( clubContact = @@ -502,17 +521,18 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + fileRepository.findAllActiveByOwnerTypeAndOwnerId( 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)) } + existingFile.isDeleted shouldBe true + existingFile.deletedAt shouldBe LocalDateTime.now(clock) + existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } } } @@ -547,8 +567,8 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe null } - it("기존 File 레코드가 삭제된다") { - val existingFile = mockk(relaxed = true) + it("기존 File 레코드가 삭제 예약된다") { + val existingFile = createClubFile(FileOwnerType.CLUB_BACKGROUND, "old_background.png") val club = ClubTestFixture.createClub( clubContact = @@ -562,17 +582,18 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + fileRepository.findAllActiveByOwnerTypeAndOwnerId( 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)) } + existingFile.isDeleted shouldBe true + existingFile.deletedAt shouldBe LocalDateTime.now(clock) + existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + verify(exactly = 0) { fileRepository.deleteAll(any>()) } } } }) 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 e1093225..db9f3660 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 @@ -151,7 +151,7 @@ class ManageCommentUseCaseTest : comment.content shouldBe "new content" oldFile.isDeleted shouldBe true oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock).plusDays(30) + oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) verify(exactly = 0) { fileRepository.deleteAll(any>()) } verify { fileRepository.saveAll(listOf(newFile)) } } 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 99219f94..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 @@ -160,4 +160,25 @@ class FileTest : 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 + } + } }) From 5aa3d9e1e7e1f3b14e05c844bce0af571cd3cea1 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 22:25:13 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=98=88=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/domain/repository/PostRepository.kt | 16 +++++ .../service/ClubActivityDeletionPolicy.kt | 36 ++++++++++ .../domain/repository/CommentRepository.kt | 17 +++++ .../service/ClubActivityDeletionPolicyTest.kt | 65 ++++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) 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..d1341eab 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,20 @@ interface PostRepository : fun countActivePostsByBoardIds( @Param("boardIds") boardIds: List, ): List + + @Query( + """ + SELECT p.id + FROM Post p + WHERE p.clubMember.id = :clubMemberId + AND p.board.club.id = :clubId + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.id ASC + """, + ) + fun findActiveIdsByClubMemberIdAndClubId( + @Param("clubMemberId") clubMemberId: Long, + @Param("clubId") clubId: Long, + ): List } 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..1d574847 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,9 @@ 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.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -19,10 +22,43 @@ 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, + ) { + markMemberFilesDeleted(member, now) + markMemberPostLikesDeleted(member, now) + } + + private fun markMemberFilesDeleted( + member: ClubMember, + now: LocalDateTime, + ) { + val postIds = postRepository.findActiveIdsByClubMemberIdAndClubId(member.id, member.club.id) + val commentIds = commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, member.club.id) + + markFilesDeleted(FileOwnerType.POST, postIds, now) + markFilesDeleted(FileOwnerType.COMMENT, commentIds, now) + } + + private fun markFilesDeleted( + ownerType: FileOwnerType, + ownerIds: List, + now: LocalDateTime, + ) { + if (ownerIds.isEmpty()) return + + fileRepository + .findAllActiveByOwnerTypeAndOwnerIdIn(ownerType, ownerIds) + .forEach { it.markDeleted(now) } + } + + private fun markMemberPostLikesDeleted( + member: ClubMember, + now: LocalDateTime, ) { val postIds = postLikeRepository 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..dca5bf67 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,21 @@ interface CommentRepository : override fun findAllByPostId( @Param("postId") postId: Long, ): List + + @Query( + """ + SELECT c.id + FROM Comment c + WHERE c.clubMember.id = :clubMemberId + AND c.post.board.club.id = :clubId + AND c.isDeleted = false + AND c.post.isDeleted = false + AND c.post.board.isDeleted = false + ORDER BY c.id ASC + """, + ) + fun findActiveIdsByClubMemberIdAndClubId( + @Param("clubMemberId") clubMemberId: Long, + @Param("clubId") clubId: Long, + ): List } 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..7b3b0d15 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,10 @@ 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.file.fixture.FileTestFixture import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -21,10 +25,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") { @@ -47,6 +53,9 @@ class ClubActivityDeletionPolicyTest : every { postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) } returns listOf(post.id) + every { postRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() + every { fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(any(), any()) } returns emptyList() every { postRepository.findAllByIdsWithLock(listOf(post.id)) } returns listOf(post) every { postLikeRepository.findAllActiveByUserIdAndPostIds(member.user.id, listOf(post.id)) @@ -79,11 +88,63 @@ class ClubActivityDeletionPolicyTest : every { postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() + every { fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(any(), any()) } returns emptyList() policy.markMemberActivitiesDeleted(member, LocalDateTime.of(2026, 5, 19, 12, 0)) verify(exactly = 0) { postRepository.findAllByIdsWithLock(any()) } verify(exactly = 0) { postLikeRepository.findAllActiveByUserIdAndPostIds(any(), any()) } } + + 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) + val postFile = + FileTestFixture.createFile( + id = 1L, + fileName = "post.png", + ownerType = FileOwnerType.POST, + ownerId = 100L, + ) + val commentFile = + FileTestFixture.createFile( + id = 2L, + fileName = "comment.png", + ownerType = FileOwnerType.COMMENT, + ownerId = 200L, + ) + + every { + postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns + listOf(100L, 101L) + every { commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns + listOf(200L) + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + } returns listOf(postFile) + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + } returns listOf(commentFile) + + policy.markMemberActivitiesDeleted(member, now) + + postFile.isDeleted shouldBe true + postFile.deletedAt shouldBe now + postFile.hardDeleteAfter shouldBe now.plusDays(30) + commentFile.isDeleted shouldBe true + commentFile.deletedAt shouldBe now + commentFile.hardDeleteAfter shouldBe now.plusDays(30) + verify(exactly = 0) { postRepository.findAllByIdsWithLock(any()) } + } } }) From 78760a4121ddbed8395977a0d0c1f90a3cd88dd7 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 22:33:49 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=EC=9C=84=EB=93=9C=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20=EC=98=88=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/LeaveUserUseCase.kt | 16 ++++++ .../usecase/command/LeaveUserUseCaseTest.kt | 51 +++++++++++++++++++ 2 files changed, 67 insertions(+) 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..bbdbc89f 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,8 @@ 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.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 +23,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, @@ -43,10 +46,23 @@ class LeaveUserUseCase( member.leave(now) } + markClubMemberProfileFilesDeleted(userId, now) user.leave(now) revokeTokensAfterCommit(userId) } + /** + * 위드 탈퇴는 서비스 전체 탈퇴이므로 user-scope 멤버 프로필 파일을 30일 보관 삭제 예약 + */ + private fun markClubMemberProfileFilesDeleted( + userId: Long, + now: LocalDateTime, + ) { + fileRepository + .findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, userId) + .forEach { it.markDeleted(now) } + } + private fun revokeTokensAfterCommit(userId: Long) { TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { 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..1dfc63b4 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,9 @@ 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.file.fixture.FileTestFixture 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 +34,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 +44,7 @@ class LeaveUserUseCaseTest : userReader = userReader, clubMemberRepository = clubMemberRepository, clubActivityDeletionPolicy = clubActivityDeletionPolicy, + fileRepository = fileRepository, jwtManageUseCase = jwtManageUseCase, accessTokenBlacklistStore = accessTokenBlacklistStore, meterRegistry = meterRegistry, @@ -51,6 +56,7 @@ class LeaveUserUseCaseTest : userReader, clubMemberRepository, clubActivityDeletionPolicy, + fileRepository, jwtManageUseCase, accessTokenBlacklistStore, ) @@ -72,6 +78,9 @@ class LeaveUserUseCaseTest : val now = LocalDateTime.now(clock) every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } returns emptyList() justRun { jwtManageUseCase.deleteRefreshToken(1L) } justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() @@ -95,6 +104,9 @@ class LeaveUserUseCaseTest : var attempts = 0 every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } returns emptyList() every { jwtManageUseCase.deleteRefreshToken(1L) } answers { attempts++ if (attempts < 3) throw RuntimeException("temporary redis failure") @@ -115,6 +127,9 @@ class LeaveUserUseCaseTest : var attempts = 0 every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } returns emptyList() justRun { jwtManageUseCase.deleteRefreshToken(1L) } every { accessTokenBlacklistStore.blacklist(1L) } answers { attempts++ @@ -134,6 +149,9 @@ class LeaveUserUseCaseTest : val user = UserTestFixture.createRegisteredUser(1L) every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } returns emptyList() every { jwtManageUseCase.deleteRefreshToken(1L) } throws RuntimeException("redis down") every { accessTokenBlacklistStore.blacklist(1L) } throws RuntimeException("redis down") TransactionSynchronizationManager.initSynchronization() @@ -174,6 +192,9 @@ class LeaveUserUseCaseTest : every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(userMember, adminMember) justRun { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } returns emptyList() justRun { jwtManageUseCase.deleteRefreshToken(1L) } justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() @@ -189,6 +210,35 @@ class LeaveUserUseCaseTest : verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(adminMember, now) } } + it("위드 탈퇴 시 멤버 프로필 파일을 30일 보관 삭제 예약한다") { + val user = UserTestFixture.createRegisteredUser(1L) + val profileFile = + FileTestFixture.createFile( + id = 100L, + fileName = "profile.png", + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = 1L, + ) + val now = LocalDateTime.now(clock) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } returns listOf(profileFile) + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + justRun { accessTokenBlacklistStore.blacklist(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + profileFile.isDeleted shouldBe true + profileFile.deletedAt shouldBe now + profileFile.hardDeleteAfter shouldBe now.plusDays(30) + verify(exactly = 1) { + fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } + } + it("ACTIVE LEAD 멤버십이 있으면 탈퇴를 차단하고 상태를 변경하지 않는다") { val user = UserTestFixture.createRegisteredUser(1L) val leadMember = @@ -207,6 +257,7 @@ class LeaveUserUseCaseTest : user.status shouldBe Status.ACTIVE leadMember.memberStatus shouldBe MemberStatus.ACTIVE verify(exactly = 0) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + verify(exactly = 0) { fileRepository.findAllActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } } } From 1c83c433d916d59a924c01f45ca5faf33a69b769 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 22:41:54 +0900 Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20=EC=9C=84=EB=93=9C=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=82=AD=EC=A0=9C=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/PostLikeRepository.kt | 18 ++++ .../board/domain/repository/PostRepository.kt | 14 +++ .../service/ClubActivityDeletionPolicy.kt | 67 +++++++++------ .../domain/repository/CommentRepository.kt | 15 ++++ .../usecase/command/LeaveUserUseCase.kt | 6 +- .../service/ClubActivityDeletionPolicyTest.kt | 85 ++++++++++++++++--- .../usecase/command/LeaveUserUseCaseTest.kt | 8 +- 7 files changed, 172 insertions(+), 41 deletions(-) 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 d1341eab..7f417b4e 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 @@ -233,4 +233,18 @@ interface PostRepository : @Param("clubMemberId") clubMemberId: Long, @Param("clubId") clubId: Long, ): 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/domain/service/ClubActivityDeletionPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt index 1d574847..e8791e0d 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 @@ -29,16 +29,26 @@ class ClubActivityDeletionPolicy( member: ClubMember, now: LocalDateTime, ) { - markMemberFilesDeleted(member, now) - markMemberPostLikesDeleted(member, now) + markMembersActivitiesDeleted(listOf(member), now) } - private fun markMemberFilesDeleted( - member: ClubMember, + fun markMembersActivitiesDeleted( + members: List, + now: LocalDateTime, + ) { + if (members.isEmpty()) return + + markMembersFilesDeleted(members, now) + markMembersPostLikesDeleted(members, now) + } + + private fun markMembersFilesDeleted( + members: List, now: LocalDateTime, ) { - val postIds = postRepository.findActiveIdsByClubMemberIdAndClubId(member.id, member.club.id) - val commentIds = commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, member.club.id) + 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) @@ -56,30 +66,37 @@ class ClubActivityDeletionPolicy( .forEach { it.markDeleted(now) } } - private fun markMemberPostLikesDeleted( - member: ClubMember, + private fun markMembersPostLikesDeleted( + members: List, now: LocalDateTime, ) { - val postIds = - postLikeRepository - .findActivePostIdsByUserIdAndClubId( - userId = member.user.id, - clubId = member.club.id, - ).distinct() - .sorted() + 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 - if (postIds.isEmpty()) return + val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id } + if (postsById.isEmpty()) return@forEach - val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id } - if (postsById.isEmpty()) return + val likes = + postLikeRepository.findAllActiveByUserIdAndPostIds( + userId = userId, + postIds = postsById.keys.toList(), + ) - postLikeRepository - .findAllActiveByUserIdAndPostIds( - userId = member.user.id, - postIds = postsById.keys.toList(), - ).forEach { like -> - if (!like.markDeleted(now)) return@forEach - postsById.getValue(like.post.id).decreaseLikeCount() + for (like in likes) { + if (!like.markDeleted(now)) continue + postsById.getValue(like.post.id).decreaseLikeCount() + } } } } 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 dca5bf67..e06420c7 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 @@ -37,4 +37,19 @@ interface CommentRepository : @Param("clubMemberId") clubMemberId: Long, @Param("clubId") clubId: 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/user/application/usecase/command/LeaveUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt index bbdbc89f..ae3f2059 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 @@ -41,10 +41,10 @@ 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) 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 7b3b0d15..bec7edee 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 @@ -51,10 +51,10 @@ 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.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() - every { commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() every { fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(any(), any()) } returns emptyList() every { postRepository.findAllByIdsWithLock(listOf(post.id)) } returns listOf(post) every { @@ -67,7 +67,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)) @@ -86,10 +86,10 @@ class ClubActivityDeletionPolicyTest : ) every { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } returns emptyList() - every { postRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() - every { commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() every { fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(any(), any()) } returns emptyList() policy.markMemberActivitiesDeleted(member, LocalDateTime.of(2026, 5, 19, 12, 0)) @@ -123,11 +123,11 @@ class ClubActivityDeletionPolicyTest : ) every { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } returns emptyList() - every { postRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns listOf(100L, 101L) - every { commentRepository.findActiveIdsByClubMemberIdAndClubId(member.id, club.id) } returns + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns listOf(200L) every { fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) @@ -147,4 +147,69 @@ class ClubActivityDeletionPolicyTest : 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) + val postFile = + FileTestFixture.createFile( + id = 1L, + fileName = "post.png", + ownerType = FileOwnerType.POST, + ownerId = 100L, + ) + val commentFile = + FileTestFixture.createFile( + id = 2L, + fileName = "comment.png", + ownerType = FileOwnerType.COMMENT, + ownerId = 200L, + ) + + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(100L, 101L) + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(200L) + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + } returns listOf(postFile) + every { + fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + } returns listOf(commentFile) + every { + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(user.id, listOf(1L, 2L)) + } returns emptyList() + + policy.markMembersActivitiesDeleted(listOf(firstMember, secondMember), now) + + postFile.isDeleted shouldBe true + postFile.deletedAt shouldBe now + postFile.hardDeleteAfter shouldBe now.plusDays(30) + commentFile.isDeleted shouldBe true + commentFile.deletedAt shouldBe now + commentFile.hardDeleteAfter shouldBe now.plusDays(30) + verify(exactly = 1) { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } + verify(exactly = 1) { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } + verify(exactly = 1) { + fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + } + verify(exactly = 1) { + fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + } + verify(exactly = 0) { postRepository.findActiveIdsByClubMemberIdAndClubId(any(), any()) } + verify(exactly = 0) { commentRepository.findActiveIdsByClubMemberIdAndClubId(any(), any()) } + } + } }) 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 1dfc63b4..0c911849 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 @@ -191,7 +191,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()) } every { fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) } returns emptyList() @@ -206,8 +206,10 @@ 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일 보관 삭제 예약한다") { From b6caa272a6f7cefd1a07b6722f11dfa2bd71b6e4 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 25 Jun 2026 22:52:41 +0900 Subject: [PATCH 08/15] =?UTF-8?q?test:=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/query/GetPostQueryServiceTest.kt | 11 ++++-- .../query/GetCommentQueryServiceTest.kt | 8 ++-- .../domain/repository/FileRepositoryTest.kt | 39 +++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) 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/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/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index 34ccd3a5..ad3b4c03 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 @@ -47,6 +47,45 @@ class FileRepositoryTest( } describe("findAll/exists") { + 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)) From ab0c53699cda0f0f0255d9f1bf3633bc230e7251 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 26 Jun 2026 15:31:00 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageClubMemberUsecase.kt | 4 ++-- .../command/ManageClubMemberUseCaseTest.kt | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) 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 0bb9b2f6..c1f67d86 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 @@ -89,7 +89,7 @@ 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 -> @@ -119,7 +119,7 @@ class ManageClubMemberUsecase( @Transactional fun deleteProfileImage(userId: Long) { - val members = clubMemberRepository.findActiveByUserId(userId) + val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId) if (members.isEmpty()) throw ClubMemberNotFoundException() val existingFiles = 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 f9fb10a1..0bc1f287 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 @@ -109,7 +109,8 @@ class ManageClubMemberUseCaseTest : ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, ownerId = userId, ) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) every { fileRepository.findAllActiveByOwnerTypeAndOwnerId( FileOwnerType.CLUB_MEMBER_PROFILE, @@ -125,6 +126,8 @@ class ManageClubMemberUseCaseTest : existingFile.hardDeleteAfter shouldBe 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) } } } @@ -133,7 +136,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 = "안녕하세요!")) @@ -149,7 +153,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 = "")) @@ -160,7 +165,7 @@ class ManageClubMemberUseCaseTest : context("활성 동아리 멤버십이 없을 때") { it("ClubMemberNotFoundException을 던진다") { - every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns emptyList() shouldThrow { useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) @@ -186,7 +191,8 @@ class ManageClubMemberUseCaseTest : ownerId = userId, ) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) every { fileRepository.findAllActiveByOwnerTypeAndOwnerId( FileOwnerType.CLUB_MEMBER_PROFILE, @@ -200,6 +206,8 @@ class ManageClubMemberUseCaseTest : existingFile.deletedAt shouldBe LocalDateTime.now(clock) existingFile.hardDeleteAfter shouldBe 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 } @@ -207,7 +215,7 @@ class ManageClubMemberUseCaseTest : context("활성 동아리 멤버십이 없을 때") { it("ClubMemberNotFoundException을 던진다") { - every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns emptyList() shouldThrow { useCase.deleteProfileImage(userId) From c2cccc179eaec5817f3cdfac41c535c59471e3ce Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 26 Jun 2026 15:56:16 +0900 Subject: [PATCH 10/15] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=98=88=EC=95=BD=20bulk=20update?= =?UTF-8?q?=EB=A1=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManageReceiptUseCase.kt | 10 +- .../usecase/command/ManagePostUseCase.kt | 11 +- .../command/ManageClubMemberUsecase.kt | 23 ++-- .../usecase/command/ManageClubUseCase.kt | 8 +- .../service/ClubActivityDeletionPolicy.kt | 10 +- .../usecase/command/ManageCommentUseCase.kt | 11 +- .../weeth/domain/file/domain/entity/File.kt | 2 +- .../file/domain/repository/FileRepository.kt | 77 ++++++------ .../usecase/command/LeaveUserUseCase.kt | 10 +- .../command/ManageReceiptUseCaseTest.kt | 48 ++++---- .../usecase/command/ManagePostUseCaseTest.kt | 38 +++--- .../command/ManageClubMemberUseCaseTest.kt | 55 ++++----- .../usecase/command/ManageClubUseCaseTest.kt | 60 ++++------ .../service/ClubActivityDeletionPolicyTest.kt | 111 +++++++++--------- .../command/ManageCommentUseCaseTest.kt | 28 ++--- .../domain/repository/FileRepositoryTest.kt | 89 ++++++++++++++ .../usecase/command/LeaveUserUseCaseTest.kt | 41 ++----- 17 files changed, 351 insertions(+), 281 deletions(-) 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 119dad1c..6e09d48d 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 @@ -13,7 +13,6 @@ 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.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 @@ -25,7 +24,6 @@ import java.time.LocalDateTime 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, @@ -102,7 +100,11 @@ class ManageReceiptUseCase( */ private fun markReceiptFilesDeleted(receiptId: Long) { val now = LocalDateTime.now(clock) - val files = fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) - files.forEach { it.markDeletedForImmediateCleanup(now) } + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.RECEIPT, + ownerId = receiptId, + deletedAt = now, + hardDeleteAfter = 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 6914cc74..11d1bfb0 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 @@ -18,7 +18,6 @@ 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.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 @@ -32,7 +31,6 @@ 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, @@ -160,9 +158,12 @@ class ManagePostUseCase( * 게시글 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 */ private fun deletePostFiles(postId: Long) { - val files = fileReader.findAll(FileOwnerType.POST, postId) - val now = LocalDateTime.now(clock) - files.forEach { it.markDeletedForImmediateCleanup(now) } + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.POST, + ownerId = postId, + deletedAt = now, + hardDeleteAfter = now, + ) } } 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 c1f67d86..a513d258 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 @@ -93,12 +93,7 @@ class ManageClubMemberUsecase( if (members.isEmpty()) throw ClubMemberNotFoundException() request.profileImage?.let { profileImage -> - val existingFiles = - fileRepository.findAllActiveByOwnerTypeAndOwnerId( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - ) - markFilesDeleted(existingFiles) + markFilesDeleted(userId) val file = File.createUploaded( @@ -122,12 +117,7 @@ class ManageClubMemberUsecase( val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId) if (members.isEmpty()) throw ClubMemberNotFoundException() - val existingFiles = - fileRepository.findAllActiveByOwnerTypeAndOwnerId( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - ) - markFilesDeleted(existingFiles) + markFilesDeleted(userId) members.forEach { it.removeProfileImage() } } @@ -181,8 +171,13 @@ class ManageClubMemberUsecase( /** * 프로필 이미지 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 */ - private fun markFilesDeleted(files: List) { + private fun markFilesDeleted(userId: Long) { val now = LocalDateTime.now(clock) - files.forEach { it.markDeletedForImmediateCleanup(now) } + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + deletedAt = now, + hardDeleteAfter = 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 53000336..b30c5b39 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 @@ -245,8 +245,12 @@ class ManageClubUseCase( ownerId: Long, ) { val now = LocalDateTime.now(clock) - val files = fileRepository.findAllActiveByOwnerTypeAndOwnerId(ownerType, ownerId) - files.forEach { it.markDeletedForImmediateCleanup(now) } + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = ownerType, + ownerId = ownerId, + deletedAt = now, + hardDeleteAfter = 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 e8791e0d..cb2178eb 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 @@ -4,6 +4,7 @@ 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 @@ -61,9 +62,12 @@ class ClubActivityDeletionPolicy( ) { if (ownerIds.isEmpty()) return - fileRepository - .findAllActiveByOwnerTypeAndOwnerIdIn(ownerType, ownerIds) - .forEach { it.markDeleted(now) } + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + ownerType = ownerType, + ownerIds = ownerIds, + deletedAt = now, + hardDeleteAfter = now.plusDays(File.RETENTION_DAYS), + ) } private fun markMembersPostLikesDeleted( 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 79bfbc5f..4e2b8d6c 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 @@ -15,7 +15,6 @@ 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.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 @@ -27,7 +26,6 @@ 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, @@ -143,10 +141,13 @@ class ManageCommentUseCase( * 댓글 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시 */ private fun deleteCommentFiles(commentId: Long) { - val files = fileReader.findAll(FileOwnerType.COMMENT, commentId) - val now = LocalDateTime.now(clock) - files.forEach { it.markDeletedForImmediateCleanup(now) } + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.COMMENT, + ownerId = commentId, + deletedAt = now, + hardDeleteAfter = now, + ) } private fun ensureOwner( 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 13b28bb2..737418e4 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 @@ -86,7 +86,7 @@ class File( } companion object { - private const val RETENTION_DAYS = 30L + const val RETENTION_DAYS = 30L private const val IMMEDIATE_CLEANUP_DAYS = 0L fun createUploaded( 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 d32aed60..6106dbae 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,21 +4,14 @@ 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( - ownerType: FileOwnerType, - ownerId: Long, - ): List - - fun findAllByOwnerTypeAndOwnerIdAndStatus( - ownerType: FileOwnerType, - ownerId: Long, - status: FileStatus, - ): List - fun findAllByOwnerTypeAndOwnerIdAndIsDeletedFalse( ownerType: FileOwnerType, ownerId: Long, @@ -30,17 +23,6 @@ interface FileRepository : status: FileStatus, ): List - fun findAllByOwnerTypeAndOwnerIdIn( - ownerType: FileOwnerType, - ownerIds: List, - ): List - - fun findAllByOwnerTypeAndOwnerIdInAndStatus( - ownerType: FileOwnerType, - ownerIds: List, - status: FileStatus, - ): List - fun findAllByOwnerTypeAndOwnerIdInAndIsDeletedFalse( ownerType: FileOwnerType, ownerIds: List, @@ -52,17 +34,6 @@ interface FileRepository : status: FileStatus, ): List - fun existsByOwnerTypeAndOwnerId( - ownerType: FileOwnerType, - ownerId: Long, - ): Boolean - - fun existsByOwnerTypeAndOwnerIdAndStatus( - ownerType: FileOwnerType, - ownerId: Long, - status: FileStatus, - ): Boolean - fun existsByOwnerTypeAndOwnerIdAndIsDeletedFalse( ownerType: FileOwnerType, ownerId: Long, @@ -74,6 +45,46 @@ interface FileRepository : status: FileStatus, ): Boolean + @Modifying(flushAutomatically = true) + @Query( + """ + UPDATE File f + SET f.isDeleted = true, + f.deletedAt = :deletedAt, + f.hardDeleteAfter = :hardDeleteAfter + 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 + + @Modifying(flushAutomatically = true) + @Query( + """ + UPDATE File f + SET f.isDeleted = true, + f.deletedAt = :deletedAt, + f.hardDeleteAfter = :hardDeleteAfter + 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, 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 ae3f2059..fb3898f8 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,7 @@ 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 @@ -58,9 +59,12 @@ class LeaveUserUseCase( userId: Long, now: LocalDateTime, ) { - fileRepository - .findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, userId) - .forEach { it.markDeleted(now) } + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + deletedAt = now, + hardDeleteAfter = now.plusDays(File.RETENTION_DAYS), + ) } private fun revokeTokensAfterCommit(userId: Long) { 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 fa493896..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,7 +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.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -38,7 +37,6 @@ class ManageReceiptUseCaseTest : 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) @@ -47,7 +45,6 @@ class ManageReceiptUseCaseTest : ManageReceiptUseCase( receiptRepository, accountRepository, - fileReader, fileRepository, cardinalReader, clubPermissionPolicy, @@ -61,7 +58,6 @@ class ManageReceiptUseCaseTest : clearMocks( receiptRepository, accountRepository, - fileReader, fileRepository, cardinalReader, clubPermissionPolicy, @@ -168,21 +164,23 @@ class ManageReceiptUseCaseTest : 40, listOf(FileSaveRequest("new.png", "TEMP/2026-02/new.png", 100L, "image/png")), ) - val oldFile = createReceiptFile("old.png", receiptId) - val oldFiles = listOf(oldFile) 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) - oldFile.isDeleted shouldBe true - oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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) } } @@ -217,20 +215,21 @@ class ManageReceiptUseCaseTest : 40, emptyList(), ) - val oldFile = createReceiptFile("old.png", receiptId) - val oldFiles = listOf(oldFile) - 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) - oldFile.isDeleted shouldBe true - oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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()) } } @@ -243,17 +242,18 @@ class ManageReceiptUseCaseTest : val clubId = account.club.id val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 10_000, account = account) account.spend(Money.of(receipt.amount)) - val oldFile = createReceiptFile("old.png", receiptId) - val files = listOf(oldFile) - every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) - every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns files useCase.delete(clubId, userId, receiptId) - oldFile.isDeleted shouldBe true - oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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 11313bd6..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 @@ -50,7 +49,6 @@ class ManagePostUseCaseTest : val clubMemberPolicy = mockk(relaxed = true) val clubMemberCardinalReader = mockk() val fileRepository = mockk() - val fileReader = mockk() val fileMapper = mockk() val postMapper = mockk() @@ -61,7 +59,6 @@ class ManagePostUseCaseTest : clubMemberPolicy, clubMemberCardinalReader, fileRepository, - fileReader, fileMapper, postMapper, clock, @@ -87,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 @@ -202,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>()) } } @@ -212,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( @@ -230,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 @@ -239,9 +237,14 @@ class ManagePostUseCaseTest : post.title shouldBe "수정" post.content shouldBe "수정" - oldFile.isDeleted shouldBe true - oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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) } } @@ -340,18 +343,21 @@ 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") 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 - oldFile.isDeleted shouldBe true - oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 0bc1f287..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 @@ -25,7 +25,6 @@ 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.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 @@ -86,6 +85,7 @@ class ManageClubMemberUseCaseTest : ) every { clubMemberRepository.save(any()) } answers { firstArg() } every { fileRepository.save(any()) } answers { firstArg() } + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } returns 0 } describe("updateProfile") { @@ -102,28 +102,21 @@ class ManageClubMemberUseCaseTest : 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.findAllActiveByUserIdWithLock(userId) } returns listOf(member1, member2) - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - ) - } returns listOf(existingFile) + useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) member1.profileImageStorageKey shouldBe profileImageRequest.storageKey member2.profileImageStorageKey shouldBe profileImageRequest.storageKey - existingFile.isDeleted shouldBe true - existingFile.deletedAt shouldBe LocalDateTime.now(clock) - existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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) } @@ -143,7 +136,9 @@ class ManageClubMemberUseCaseTest : member1.bio shouldBe "안녕하세요!" member2.bio shouldBe "안녕하세요!" - verify(exactly = 0) { fileRepository.findAllActiveByOwnerTypeAndOwnerId(any(), any()) } + verify(exactly = 0) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) + } verify(exactly = 0) { fileRepository.save(any()) } } } @@ -183,28 +178,20 @@ class ManageClubMemberUseCaseTest : 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.findAllActiveByUserIdWithLock(userId) } returns listOf(member1, member2) - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - ) - } returns listOf(existingFile) useCase.deleteProfileImage(userId) - existingFile.isDeleted shouldBe true - existingFile.deletedAt shouldBe LocalDateTime.now(clock) - existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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) } 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 692a287f..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 @@ -96,9 +96,7 @@ 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.findAllActiveByOwnerTypeAndOwnerId(any(), any()) - } returns emptyList() + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } returns 0 } fun createClubFile( @@ -388,7 +386,6 @@ class ManageClubUseCaseTest : } it("프로필 이미지를 변경하면 기존 File이 삭제 예약되고 새 File이 생성된다") { - val existingFile = createClubFile(FileOwnerType.CLUB_PROFILE, "old_profile.png") val club = ClubTestFixture.createClub( clubContact = @@ -401,12 +398,6 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId( - FileOwnerType.CLUB_PROFILE, - 1L, - ) - } returns listOf(existingFile) useCase.update( 1L, @@ -422,9 +413,14 @@ class ManageClubUseCaseTest : ), ) - existingFile.isDeleted shouldBe true - existingFile.deletedAt shouldBe LocalDateTime.now(clock) - existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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" @@ -446,7 +442,7 @@ class ManageClubUseCaseTest : useCase.update(1L, 10L, ClubUpdateRequest(name = "새 이름")) verify(exactly = 0) { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(any(), any()) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } verify(exactly = 0) { fileRepository.save(any()) } } @@ -507,7 +503,6 @@ class ManageClubUseCaseTest : } it("기존 File 레코드가 삭제 예약된다") { - val existingFile = createClubFile(FileOwnerType.CLUB_PROFILE, "old_profile.png") val club = ClubTestFixture.createClub( clubContact = @@ -520,18 +515,17 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId( - FileOwnerType.CLUB_PROFILE, - 1L, - ) - } returns listOf(existingFile) useCase.deleteProfileImage(1L, 10L) - existingFile.isDeleted shouldBe true - existingFile.deletedAt shouldBe LocalDateTime.now(clock) - existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + verify(exactly = 1) { + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_PROFILE, + 1L, + LocalDateTime.now(clock), + LocalDateTime.now(clock), + ) + } verify(exactly = 0) { fileRepository.deleteAll(any>()) } } } @@ -568,7 +562,6 @@ class ManageClubUseCaseTest : } it("기존 File 레코드가 삭제 예약된다") { - val existingFile = createClubFile(FileOwnerType.CLUB_BACKGROUND, "old_background.png") val club = ClubTestFixture.createClub( clubContact = @@ -581,18 +574,17 @@ class ManageClubUseCaseTest : every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId( - FileOwnerType.CLUB_BACKGROUND, - 1L, - ) - } returns listOf(existingFile) useCase.deleteBackgroundImage(1L, 10L) - existingFile.isDeleted shouldBe true - existingFile.deletedAt shouldBe LocalDateTime.now(clock) - existingFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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 bec7edee..d5618d02 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 @@ -10,7 +10,6 @@ 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.file.fixture.FileTestFixture import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -55,7 +54,7 @@ class ClubActivityDeletionPolicyTest : } returns listOf(post.id) every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() - every { fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(any(), any()) } 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)) @@ -90,7 +89,7 @@ class ClubActivityDeletionPolicyTest : } returns emptyList() every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() - every { fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(any(), any()) } returns emptyList() + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn(any(), any(), any(), any()) } returns 0 policy.markMemberActivitiesDeleted(member, LocalDateTime.of(2026, 5, 19, 12, 0)) @@ -107,20 +106,6 @@ class ClubActivityDeletionPolicyTest : user = UserTestFixture.createActiveUser1(id = 20L), ) val now = LocalDateTime.of(2026, 5, 19, 12, 0) - val postFile = - FileTestFixture.createFile( - id = 1L, - fileName = "post.png", - ownerType = FileOwnerType.POST, - ownerId = 100L, - ) - val commentFile = - FileTestFixture.createFile( - id = 2L, - fileName = "comment.png", - ownerType = FileOwnerType.COMMENT, - ownerId = 200L, - ) every { postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) @@ -130,20 +115,40 @@ class ClubActivityDeletionPolicyTest : every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns listOf(200L) every { - fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) - } returns listOf(postFile) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + listOf(100L, 101L), + now, + now.plusDays(30), + ) + } returns 1 every { - fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) - } returns listOf(commentFile) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.COMMENT, + listOf(200L), + now, + now.plusDays(30), + ) + } returns 1 policy.markMemberActivitiesDeleted(member, now) - postFile.isDeleted shouldBe true - postFile.deletedAt shouldBe now - postFile.hardDeleteAfter shouldBe now.plusDays(30) - commentFile.isDeleted shouldBe true - commentFile.deletedAt shouldBe now - commentFile.hardDeleteAfter shouldBe now.plusDays(30) + 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()) } } } @@ -165,48 +170,48 @@ class ClubActivityDeletionPolicyTest : user = user, ) val now = LocalDateTime.of(2026, 5, 19, 12, 0) - val postFile = - FileTestFixture.createFile( - id = 1L, - fileName = "post.png", - ownerType = FileOwnerType.POST, - ownerId = 100L, - ) - val commentFile = - FileTestFixture.createFile( - id = 2L, - fileName = "comment.png", - ownerType = FileOwnerType.COMMENT, - ownerId = 200L, - ) every { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(100L, 101L) every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(200L) every { - fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) - } returns listOf(postFile) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + listOf(100L, 101L), + now, + now.plusDays(30), + ) + } returns 1 every { - fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) - } returns listOf(commentFile) + 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) - postFile.isDeleted shouldBe true - postFile.deletedAt shouldBe now - postFile.hardDeleteAfter shouldBe now.plusDays(30) - commentFile.isDeleted shouldBe true - commentFile.deletedAt shouldBe now - commentFile.hardDeleteAfter shouldBe now.plusDays(30) verify(exactly = 1) { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } verify(exactly = 1) { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } verify(exactly = 1) { - fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + listOf(100L, 101L), + now, + now.plusDays(30), + ) } verify(exactly = 1) { - fileRepository.findAllActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn( + FileOwnerType.COMMENT, + listOf(200L), + now, + now.plusDays(30), + ) } verify(exactly = 0) { postRepository.findActiveIdsByClubMemberIdAndClubId(any(), any()) } verify(exactly = 0) { commentRepository.findActiveIdsByClubMemberIdAndClubId(any(), any()) } 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 db9f3660..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 @@ -40,7 +38,6 @@ class ManageCommentUseCaseTest : val commentRepository = mockk(relaxUnitFun = true) val postRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) - val fileReader = mockk() val fileRepository = mockk(relaxed = true) val fileMapper = mockk() @@ -49,17 +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 } @@ -123,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", @@ -143,15 +129,19 @@ 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" - oldFile.isDeleted shouldBe true - oldFile.deletedAt shouldBe LocalDateTime.now(clock) - oldFile.hardDeleteAfter shouldBe LocalDateTime.now(clock) + 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/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index ad3b4c03..6a8adcae 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 @@ -25,6 +25,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 FROM `file` WHERE id = ?", + fileId, + ) + describe("save") { it("파일 정보를 저장하고 조회한다") { val saved = @@ -142,6 +148,75 @@ class FileRepositoryTest( } } + 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(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("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(second.id).booleanBy("is_deleted").shouldBeTrue() + row(second.id).localDateTimeBy("hard_delete_after") shouldBe hardDeleteAfter + row(otherOwner.id).booleanBy("is_deleted").shouldBeFalse() + } + } + describe("index usage") { it("owner_type + owner_id 조건 조회 시 복합 인덱스를 사용한다") { fileRepository.save(createTestFile("index-target.png", FileOwnerType.RECEIPT, 55L, FileStatus.UPLOADED)) @@ -193,3 +268,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 -> value.toString().toBoolean() + } + +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 0c911849..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 @@ -7,7 +7,6 @@ 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.file.fixture.FileTestFixture import com.weeth.domain.user.application.exception.UserHasLeadClubException import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader @@ -60,6 +59,7 @@ class LeaveUserUseCaseTest : jwtManageUseCase, accessTokenBlacklistStore, ) + every { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } returns 0 if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.clearSynchronization() } @@ -78,9 +78,6 @@ class LeaveUserUseCaseTest : val now = LocalDateTime.now(clock) every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) - } returns emptyList() justRun { jwtManageUseCase.deleteRefreshToken(1L) } justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() @@ -104,9 +101,6 @@ class LeaveUserUseCaseTest : var attempts = 0 every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) - } returns emptyList() every { jwtManageUseCase.deleteRefreshToken(1L) } answers { attempts++ if (attempts < 3) throw RuntimeException("temporary redis failure") @@ -127,9 +121,6 @@ class LeaveUserUseCaseTest : var attempts = 0 every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) - } returns emptyList() justRun { jwtManageUseCase.deleteRefreshToken(1L) } every { accessTokenBlacklistStore.blacklist(1L) } answers { attempts++ @@ -149,9 +140,6 @@ class LeaveUserUseCaseTest : val user = UserTestFixture.createRegisteredUser(1L) every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) - } returns emptyList() every { jwtManageUseCase.deleteRefreshToken(1L) } throws RuntimeException("redis down") every { accessTokenBlacklistStore.blacklist(1L) } throws RuntimeException("redis down") TransactionSynchronizationManager.initSynchronization() @@ -192,9 +180,6 @@ class LeaveUserUseCaseTest : every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(userMember, adminMember) justRun { clubActivityDeletionPolicy.markMembersActivitiesDeleted(any(), any()) } - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) - } returns emptyList() justRun { jwtManageUseCase.deleteRefreshToken(1L) } justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() @@ -214,30 +199,22 @@ class LeaveUserUseCaseTest : it("위드 탈퇴 시 멤버 프로필 파일을 30일 보관 삭제 예약한다") { val user = UserTestFixture.createRegisteredUser(1L) - val profileFile = - FileTestFixture.createFile( - id = 100L, - fileName = "profile.png", - ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, - ownerId = 1L, - ) val now = LocalDateTime.now(clock) every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() - every { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) - } returns listOf(profileFile) justRun { jwtManageUseCase.deleteRefreshToken(1L) } justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() useCase.execute(1L) - profileFile.isDeleted shouldBe true - profileFile.deletedAt shouldBe now - profileFile.hardDeleteAfter shouldBe now.plusDays(30) verify(exactly = 1) { - fileRepository.findAllActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + fileRepository.markActiveDeletedByOwnerTypeAndOwnerId( + FileOwnerType.CLUB_MEMBER_PROFILE, + 1L, + now, + now.plusDays(30), + ) } } @@ -259,7 +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.findAllActiveByOwnerTypeAndOwnerId(any(), any()) } + verify( + exactly = 0, + ) { fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(any(), any(), any(), any()) } verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } } } From 176fdc2a5aa5b5b93d6fca580c9893115f365ee6 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 26 Jun 2026 16:04:19 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=BF=BC=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/domain/repository/PostRepository.kt | 16 ---------------- .../domain/repository/CommentRepository.kt | 17 ----------------- .../file/domain/repository/FileRepository.kt | 6 ++++-- .../service/ClubActivityDeletionPolicyTest.kt | 2 -- .../domain/repository/FileRepositoryTest.kt | 5 ++++- 5 files changed, 8 insertions(+), 38 deletions(-) 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 7f417b4e..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 @@ -218,22 +218,6 @@ interface PostRepository : @Param("boardIds") boardIds: List, ): List - @Query( - """ - SELECT p.id - FROM Post p - WHERE p.clubMember.id = :clubMemberId - AND p.board.club.id = :clubId - AND p.isDeleted = false - AND p.board.isDeleted = false - ORDER BY p.id ASC - """, - ) - fun findActiveIdsByClubMemberIdAndClubId( - @Param("clubMemberId") clubMemberId: Long, - @Param("clubId") clubId: Long, - ): List - @Query( """ SELECT p.id 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 e06420c7..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 @@ -21,23 +21,6 @@ interface CommentRepository : @Param("postId") postId: Long, ): List - @Query( - """ - SELECT c.id - FROM Comment c - WHERE c.clubMember.id = :clubMemberId - AND c.post.board.club.id = :clubId - AND c.isDeleted = false - AND c.post.isDeleted = false - AND c.post.board.isDeleted = false - ORDER BY c.id ASC - """, - ) - fun findActiveIdsByClubMemberIdAndClubId( - @Param("clubMemberId") clubMemberId: Long, - @Param("clubId") clubId: Long, - ): List - @Query( """ SELECT c.id 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 6106dbae..1d503a6e 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 @@ -51,7 +51,8 @@ interface FileRepository : UPDATE File f SET f.isDeleted = true, f.deletedAt = :deletedAt, - f.hardDeleteAfter = :hardDeleteAfter + 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 @@ -71,7 +72,8 @@ interface FileRepository : UPDATE File f SET f.isDeleted = true, f.deletedAt = :deletedAt, - f.hardDeleteAfter = :hardDeleteAfter + 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 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 d5618d02..64d2d95b 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 @@ -213,8 +213,6 @@ class ClubActivityDeletionPolicyTest : now.plusDays(30), ) } - verify(exactly = 0) { postRepository.findActiveIdsByClubMemberIdAndClubId(any(), any()) } - verify(exactly = 0) { commentRepository.findActiveIdsByClubMemberIdAndClubId(any(), any()) } } } }) 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 6a8adcae..e472a38e 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 @@ -27,7 +27,7 @@ class FileRepositoryTest( ) : DescribeSpec({ fun row(fileId: Long): Map = jdbcTemplate.queryForMap( - "SELECT is_deleted, deleted_at, hard_delete_after FROM `file` WHERE id = ?", + "SELECT is_deleted, deleted_at, hard_delete_after, modified_at FROM `file` WHERE id = ?", fileId, ) @@ -183,6 +183,7 @@ class FileRepositoryTest( 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() @@ -211,8 +212,10 @@ class FileRepositoryTest( 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() } } From 285499119e67a9cc218887a9ad141bbf105b49a3 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 26 Jun 2026 16:11:11 +0900 Subject: [PATCH 12/15] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/command/ManageReceiptUseCase.kt | 3 ++- .../board/application/usecase/command/ManagePostUseCase.kt | 3 ++- .../application/usecase/command/ManageClubMemberUsecase.kt | 2 +- .../club/application/usecase/command/ManageClubUseCase.kt | 2 +- .../club/domain/service/ClubActivityDeletionPolicy.kt | 2 +- .../application/usecase/command/ManageCommentUseCase.kt | 3 ++- src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt | 6 +++++- .../weeth/domain/file/domain/repository/FileRepository.kt | 2 ++ .../user/application/usecase/command/LeaveUserUseCase.kt | 2 +- 9 files changed, 17 insertions(+), 8 deletions(-) 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 6e09d48d..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,6 +12,7 @@ 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.FileRepository import org.springframework.data.repository.findByIdOrNull @@ -104,7 +105,7 @@ class ManageReceiptUseCase( ownerType = FileOwnerType.RECEIPT, ownerId = receiptId, deletedAt = now, - hardDeleteAfter = 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 11d1bfb0..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,6 +17,7 @@ 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.FileRepository import org.springframework.stereotype.Service @@ -163,7 +164,7 @@ class ManagePostUseCase( ownerType = FileOwnerType.POST, ownerId = postId, deletedAt = now, - hardDeleteAfter = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), ) } } 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 a513d258..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 @@ -177,7 +177,7 @@ class ManageClubMemberUsecase( ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, ownerId = userId, deletedAt = now, - hardDeleteAfter = 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 b30c5b39..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 @@ -249,7 +249,7 @@ class ManageClubUseCase( ownerType = ownerType, ownerId = ownerId, deletedAt = now, - hardDeleteAfter = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), ) } 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 cb2178eb..f18e9371 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 @@ -66,7 +66,7 @@ class ClubActivityDeletionPolicy( ownerType = ownerType, ownerIds = ownerIds, deletedAt = now, - hardDeleteAfter = now.plusDays(File.RETENTION_DAYS), + hardDeleteAfter = File.retainedHardDeleteAfter(now), ) } 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 4e2b8d6c..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,6 +14,7 @@ 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.FileRepository import org.springframework.stereotype.Service @@ -146,7 +147,7 @@ class ManageCommentUseCase( ownerType = FileOwnerType.COMMENT, ownerId = commentId, deletedAt = now, - hardDeleteAfter = now, + hardDeleteAfter = File.immediateHardDeleteAfter(now), ) } 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 737418e4..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 @@ -86,9 +86,13 @@ class File( } companion object { - const val RETENTION_DAYS = 30L + 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 1d503a6e..c42ffbd0 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 @@ -45,6 +45,7 @@ interface FileRepository : status: FileStatus, ): Boolean + // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 @Modifying(flushAutomatically = true) @Query( """ @@ -66,6 +67,7 @@ interface FileRepository : @Param("hardDeleteAfter") hardDeleteAfter: LocalDateTime, ): Int + // // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 @Modifying(flushAutomatically = true) @Query( """ 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 fb3898f8..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 @@ -63,7 +63,7 @@ class LeaveUserUseCase( ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, ownerId = userId, deletedAt = now, - hardDeleteAfter = now.plusDays(File.RETENTION_DAYS), + hardDeleteAfter = File.retainedHardDeleteAfter(now), ) } From 9c24a452480692d884765c3f4e26db4abc59a669 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 26 Jun 2026 16:13:55 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/weeth/domain/file/domain/repository/FileRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c42ffbd0..4aeeea79 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 @@ -67,7 +67,7 @@ interface FileRepository : @Param("hardDeleteAfter") hardDeleteAfter: LocalDateTime, ): Int - // // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 + // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 @Modifying(flushAutomatically = true) @Query( """ From 02268e89de4dd869ad7f1e4404d857ba97dfd433 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 26 Jun 2026 16:40:27 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=EB=B9=84=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ClubActivityDeletionPolicy.kt | 5 +-- .../service/ClubActivityDeletionPolicyTest.kt | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) 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 f18e9371..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 @@ -89,17 +89,16 @@ class ClubActivityDeletionPolicy( if (postIds.isEmpty()) return@forEach val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id } - if (postsById.isEmpty()) return@forEach val likes = postLikeRepository.findAllActiveByUserIdAndPostIds( userId = userId, - postIds = postsById.keys.toList(), + postIds = postIds, ) for (like in likes) { if (!like.markDeleted(now)) continue - postsById.getValue(like.post.id).decreaseLikeCount() + postsById[like.post.id]?.decreaseLikeCount() } } } 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 64d2d95b..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 @@ -97,6 +97,45 @@ class ClubActivityDeletionPolicyTest : 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 = From 1e2b7aaea2142ddf086205757ba88f68812d7457 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 26 Jun 2026 16:56:13 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=83=81=ED=83=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/domain/repository/FileRepository.kt | 4 +-- .../domain/repository/FileRepositoryTest.kt | 30 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) 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 4aeeea79..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 @@ -46,7 +46,7 @@ interface FileRepository : ): Boolean // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 - @Modifying(flushAutomatically = true) + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query( """ UPDATE File f @@ -68,7 +68,7 @@ interface FileRepository : ): Int // Bulk update는 JPA auditing을 우회하여 modifiedAt을 명시적으로 갱신 - @Modifying(flushAutomatically = true) + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query( """ UPDATE File f 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 e472a38e..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 @@ -189,6 +190,25 @@ class FileRepositoryTest( 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) @@ -239,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( @@ -276,7 +304,7 @@ 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 -> value.toString().toBoolean() + else -> error("Unsupported Boolean value: $value") } private fun Map.localDateTimeBy(key: String): LocalDateTime =