Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,24 @@ import com.weeth.domain.account.domain.vo.Money
import com.weeth.domain.cardinal.domain.repository.CardinalReader
import com.weeth.domain.club.domain.service.ClubPermissionPolicy
import com.weeth.domain.file.application.mapper.FileMapper
import com.weeth.domain.file.domain.entity.File
import com.weeth.domain.file.domain.enums.FileOwnerType
import com.weeth.domain.file.domain.repository.FileReader
import com.weeth.domain.file.domain.repository.FileRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDateTime

@Service
class ManageReceiptUseCase(
private val receiptRepository: ReceiptRepository,
private val accountRepository: AccountRepository,
private val fileReader: FileReader,
private val fileRepository: FileRepository,
private val cardinalReader: CardinalReader,
private val clubPermissionPolicy: ClubPermissionPolicy,
private val fileMapper: FileMapper,
private val clock: Clock,
) {
@Transactional
fun save(
Expand Down Expand Up @@ -70,7 +72,7 @@ class ManageReceiptUseCase(
account.adjustSpend(Money.of(receipt.amount), Money.of(request.amount))

if (request.files != null) {
fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null))
markReceiptFilesDeleted(receiptId)
fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId))
}

Expand All @@ -90,7 +92,20 @@ class ManageReceiptUseCase(

receipt.account.cancelSpend(Money.of(receipt.amount))

fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null))
markReceiptFilesDeleted(receiptId)
receiptRepository.delete(receipt)
}

/**
* 영수증 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시
*/
private fun markReceiptFilesDeleted(receiptId: Long) {
val now = LocalDateTime.now(clock)
fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(
ownerType = FileOwnerType.RECEIPT,
ownerId = receiptId,
deletedAt = now,
hardDeleteAfter = File.immediateHardDeleteAfter(now),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader
import com.weeth.domain.club.domain.service.ClubMemberPolicy
import com.weeth.domain.file.application.dto.request.FileSaveRequest
import com.weeth.domain.file.application.mapper.FileMapper
import com.weeth.domain.file.domain.entity.File
import com.weeth.domain.file.domain.enums.FileOwnerType
import com.weeth.domain.file.domain.repository.FileReader
import com.weeth.domain.file.domain.repository.FileRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDateTime

@Service
class ManagePostUseCase(
Expand All @@ -30,9 +32,9 @@ class ManagePostUseCase(
private val clubMemberPolicy: ClubMemberPolicy,
private val clubMemberCardinalReader: ClubMemberCardinalReader,
private val fileRepository: FileRepository,
private val fileReader: FileReader,
private val fileMapper: FileMapper,
private val postMapper: PostMapper,
private val clock: Clock,
) {
@Transactional
fun save(
Expand Down Expand Up @@ -153,12 +155,16 @@ class ManagePostUseCase(
}
}

/**
* 게시글 파일 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시
*/
private fun deletePostFiles(postId: Long) {
val files = fileReader.findAll(FileOwnerType.POST, postId)

if (files.isNotEmpty()) {
fileRepository.deleteAll(files)
fileRepository.flush()
}
val now = LocalDateTime.now(clock)
fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 메서드는 실제로 DB에서 삭제를 하진 않는 것으로 이해가 되는데, 위 API를 실제로 호출하게 되면 file의 삭제가 이루어지지 않아서 유니크 제약조건에서 fail이 발생할 수도 있을 것 같아요!
이전에 해당 문제가 발생했어서 명시적으로 flush를 해줬던건데 (delete가 insert 보다 늦게 동작하기 때문) 이 로직에서는 해당 문제는 없을까용??

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존에는 delete가 insert보다 늦게 실행되면서 문제가 발생했는데 현재 로직에서는 삭제 마킹 update가 먼저 DB에 반영되도록 되어 있습니다!

말씀 주신 것처럼 소프트 딜리트라서 row는 남기 때문에 동일한 storageKey로 다시 insert되면 unique 제약에 걸릴 수는 있을 것 같습니당.. 하지만 현재 이미지 업로드 플로우에서는 UUID 기반으로 매번 새로운 storageKey를 생성하고 있어서 정상 플로우에서는 동일 storageKey가 재사용되지 않아 괜찮다고 판단했습니다!

ownerType = FileOwnerType.POST,
ownerId = postId,
deletedAt = now,
hardDeleteAfter = File.immediateHardDeleteAfter(now),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ interface PostLikeRepository :
@Param("clubId") clubId: Long,
): List<Long>

@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<Long>,
): List<Long>

@Query(
"""
SELECT pl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,18 @@ interface PostRepository :
fun countActivePostsByBoardIds(
@Param("boardIds") boardIds: List<Long>,
): List<BoardPostCount>

@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<Long>,
): List<Long>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
) {
/**
Expand Down Expand Up @@ -92,19 +89,11 @@ class ManageClubMemberUsecase(
userId: Long,
request: UpdateMemberProfileRequest,
) {
val members = clubMemberRepository.findActiveByUserId(userId)
val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId)
if (members.isEmpty()) throw ClubMemberNotFoundException()

request.profileImage?.let { profileImage ->
val existingFiles =
fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(
FileOwnerType.CLUB_MEMBER_PROFILE,
userId,
FileStatus.UPLOADED,
)
if (existingFiles.isNotEmpty()) {
fileRepository.deleteAll(existingFiles)
}
markFilesDeleted(userId)

val file =
File.createUploaded(
Expand All @@ -125,18 +114,10 @@ class ManageClubMemberUsecase(

@Transactional
fun deleteProfileImage(userId: Long) {
val members = clubMemberRepository.findActiveByUserId(userId)
val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId)
if (members.isEmpty()) throw ClubMemberNotFoundException()

val existingFiles =
fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(
FileOwnerType.CLUB_MEMBER_PROFILE,
userId,
FileStatus.UPLOADED,
)
if (existingFiles.isNotEmpty()) {
fileRepository.deleteAll(existingFiles)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

근데 기존에 하드 딜리트를 하던 파트를 소프트 딜리트 + 즉시 정리 대상으로 표시하는 방향으로 수정된 이유가 있을까요??
어차피 즉시 정리 대상으로 처리할 것이라면 하드 딜리트를 해도 문제가 없지 않나 싶어서요!

@soo0711 soo0711 Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 DB와 S3 삭제 시점이 분리되어 있어서 소프트 딜리트 + 즉시 정리 대상으로 바꿨습니다!

하드 딜리트를 바로 해버리면 DB row는 삭제됐는데 S3 삭제가 실패하거나 반대로 S3를 먼저 삭제했는데 이후 트랜잭션이 롤백되면서 DB에는 파일 정보가 남아 있는데 S3는 없는 상태가 생길 수 있다고 생각했습니닷

그래서 API 호출 시점에는 hardDeleteAfter = now로 즉시 정리 대상임을 표시한 뒤 클린업 과정에서 S3 삭제 성공 후 하드 딜리트를 처리하는 것이 데이터 정합성 측면에서 더 적절하다고 생각해서 해당 방식으로 수정했습니다!!

이 방향에 대해서는 어떻게 생각하시나용??

}
markFilesDeleted(userId)

members.forEach { it.removeProfileImage() }
}
Expand Down Expand Up @@ -186,4 +167,17 @@ class ManageClubMemberUsecase(
clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now)
member.leave(now)
}

/**
* 프로필 이미지 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시
*/
private fun markFilesDeleted(userId: Long) {
val now = LocalDateTime.now(clock)
fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(
ownerType = FileOwnerType.CLUB_MEMBER_PROFILE,
ownerId = userId,
deletedAt = now,
hardDeleteAfter = File.immediateHardDeleteAfter(now),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
* 동아리 관리 유스케이스
Expand All @@ -51,6 +52,7 @@ class ManageClubUseCase(
private val clubPermissionPolicy: ClubPermissionPolicy,
private val fileRepository: FileRepository,
private val clubMapper: ClubMapper,
private val clock: Clock,
) {
/**
* 새로운 동아리를 생성
Expand Down Expand Up @@ -235,15 +237,20 @@ class ManageClubUseCase(
fileRepository.save(file)
}

/**
* 동아리 이미지 교체/명시 삭제는 복구 대상이 아니므로 즉시 정리 대상으로 표시
*/
private fun deleteExistingFiles(
ownerType: FileOwnerType,
ownerId: Long,
) {
val files = fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, FileStatus.UPLOADED)

if (files.isNotEmpty()) {
fileRepository.deleteAll(files)
}
val now = LocalDateTime.now(clock)
fileRepository.markActiveDeletedByOwnerTypeAndOwnerId(
ownerType = ownerType,
ownerId = ownerId,
deletedAt = now,
hardDeleteAfter = File.immediateHardDeleteAfter(now),
)
}

private fun validatePrimaryContactEmail(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ package com.weeth.domain.club.domain.service
import com.weeth.domain.board.domain.repository.PostLikeRepository
import com.weeth.domain.board.domain.repository.PostRepository
import com.weeth.domain.club.domain.entity.ClubMember
import com.weeth.domain.comment.domain.repository.CommentRepository
import com.weeth.domain.file.domain.entity.File
import com.weeth.domain.file.domain.enums.FileOwnerType
import com.weeth.domain.file.domain.repository.FileRepository
import org.springframework.stereotype.Service
import java.time.LocalDateTime

Expand All @@ -19,31 +23,83 @@ import java.time.LocalDateTime
class ClubActivityDeletionPolicy(
private val postLikeRepository: PostLikeRepository,
private val postRepository: PostRepository,
private val commentRepository: CommentRepository,
private val fileRepository: FileRepository,
) {
fun markMemberActivitiesDeleted(
member: ClubMember,
now: LocalDateTime,
) {
val postIds =
postLikeRepository
.findActivePostIdsByUserIdAndClubId(
userId = member.user.id,
clubId = member.club.id,
).distinct()
.sorted()

if (postIds.isEmpty()) return

val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id }
if (postsById.isEmpty()) return

postLikeRepository
.findAllActiveByUserIdAndPostIds(
userId = member.user.id,
postIds = postsById.keys.toList(),
).forEach { like ->
if (!like.markDeleted(now)) return@forEach
postsById.getValue(like.post.id).decreaseLikeCount()
markMembersActivitiesDeleted(listOf(member), now)
}

fun markMembersActivitiesDeleted(
members: List<ClubMember>,
now: LocalDateTime,
) {
if (members.isEmpty()) return

markMembersFilesDeleted(members, now)
markMembersPostLikesDeleted(members, now)
}

private fun markMembersFilesDeleted(
members: List<ClubMember>,
now: LocalDateTime,
) {
val memberIds = members.map { it.id }.distinct().sorted()
val postIds = postRepository.findActiveIdsByClubMemberIdIn(memberIds)
val commentIds = commentRepository.findActiveIdsByClubMemberIdIn(memberIds)

markFilesDeleted(FileOwnerType.POST, postIds, now)
markFilesDeleted(FileOwnerType.COMMENT, commentIds, now)
}

private fun markFilesDeleted(
ownerType: FileOwnerType,
ownerIds: List<Long>,
now: LocalDateTime,
) {
if (ownerIds.isEmpty()) return

fileRepository.markActiveDeletedByOwnerTypeAndOwnerIdIn(
ownerType = ownerType,
ownerIds = ownerIds,
deletedAt = now,
hardDeleteAfter = File.retainedHardDeleteAfter(now),
)
}

private fun markMembersPostLikesDeleted(
members: List<ClubMember>,
now: LocalDateTime,
) {
members
.groupBy { it.user.id }
.forEach { (userId, userMembers) ->
val clubIds = userMembers.map { it.club.id }.distinct().sorted()
val postIds =
postLikeRepository
.findActivePostIdsByUserIdAndClubIdIn(
userId = userId,
clubIds = clubIds,
).distinct()
.sorted()

if (postIds.isEmpty()) return@forEach

val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id }

val likes =
postLikeRepository.findAllActiveByUserIdAndPostIds(
userId = userId,
postIds = postIds,
)
Comment thread
soo0711 marked this conversation as resolved.

for (like in likes) {
if (!like.markDeleted(now)) continue
postsById[like.post.id]?.decreaseLikeCount()
}
}
}
}
Loading