|
| 1 | +commit f76663028be113cb49f058a173729621d0744a78 |
| 2 | +Author: joyewon0705 < [email protected]> |
| 3 | +Date: Thu Oct 16 01:16:42 2025 +0900 |
| 4 | + |
| 5 | + Fix: 게시글 및 프로필 파일 관련 로직 보완 |
| 6 | + |
| 7 | +diff --git a/src/main/java/com/back/domain/board/post/service/PostService.java b/src/main/java/com/back/domain/board/post/service/PostService.java |
| 8 | +index becb576..4869b37 100644 |
| 9 | +--- a/src/main/java/com/back/domain/board/post/service/PostService.java |
| 10 | ++++ b/src/main/java/com/back/domain/board/post/service/PostService.java |
| 11 | +@@ -17,7 +17,7 @@ import com.back.domain.file.entity.EntityType; |
| 12 | + import com.back.domain.file.entity.FileAttachment; |
| 13 | + import com.back.domain.file.repository.AttachmentMappingRepository; |
| 14 | + import com.back.domain.file.repository.FileAttachmentRepository; |
| 15 | +-import com.back.domain.file.service.FileService; |
| 16 | ++import com.back.domain.file.service.AttachmentMappingService; |
| 17 | + import com.back.domain.user.common.entity.User; |
| 18 | + import com.back.domain.user.common.repository.UserRepository; |
| 19 | + import com.back.global.exception.CustomException; |
| 20 | +@@ -41,7 +41,7 @@ public class PostService { |
| 21 | + private final PostCategoryRepository postCategoryRepository; |
| 22 | + private final FileAttachmentRepository fileAttachmentRepository; |
| 23 | + private final AttachmentMappingRepository attachmentMappingRepository; |
| 24 | +- private final FileService fileService; |
| 25 | ++ private final AttachmentMappingService attachmentMappingService; |
| 26 | + |
| 27 | + /** |
| 28 | + * 게시글 생성 서비스 |
| 29 | +@@ -169,6 +169,7 @@ public class PostService { |
| 30 | + private List<FileAttachment> updatePostAttachments(Post post, List<Long> newImageIds, Long userId) { |
| 31 | + List<Long> newIds = (newImageIds != null) ? newImageIds : List.of(); |
| 32 | + |
| 33 | ++ // 기존 매핑 조회 |
| 34 | + List<AttachmentMapping> existingMappings = |
| 35 | + attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); |
| 36 | + List<Long> existingIds = existingMappings.stream() |
| 37 | +@@ -182,32 +183,26 @@ public class PostService { |
| 38 | + .toList(); |
| 39 | + } |
| 40 | + |
| 41 | +- // 기존 첨부 삭제 |
| 42 | +- deletePostAttachments(post, userId); |
| 43 | ++ // 기존 중 newIds에 없는 첨부만 삭제 |
| 44 | ++ attachmentMappingService.deleteRemovedAttachments(EntityType.POST, post.getId(), userId, newIds); |
| 45 | + |
| 46 | +- // 새 첨부 매핑 등록 |
| 47 | +- if (newIds.isEmpty()) return List.of(); |
| 48 | +- |
| 49 | +- List<FileAttachment> attachments = validateAndFindAttachments(newIds); |
| 50 | +- attachments.forEach(attachment -> |
| 51 | +- attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.POST, post.getId())) |
| 52 | +- ); |
| 53 | +- return attachments; |
| 54 | +- } |
| 55 | ++ // 새로 추가된 첨부만 매핑 생성 |
| 56 | ++ List<Long> addedIds = newIds.stream() |
| 57 | ++ .filter(id -> !existingIds.contains(id)) |
| 58 | ++ .toList(); |
| 59 | + |
| 60 | +- /** |
| 61 | +- * 게시글 첨부파일 삭제 (S3 + 매핑) |
| 62 | +- */ |
| 63 | +- private void deletePostAttachments(Post post, Long userId) { |
| 64 | +- List<AttachmentMapping> mappings = |
| 65 | +- attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); |
| 66 | +- for (AttachmentMapping mapping : mappings) { |
| 67 | +- FileAttachment file = mapping.getFileAttachment(); |
| 68 | +- if (file != null) { |
| 69 | +- fileService.deleteFile(file.getId(), userId); |
| 70 | +- } |
| 71 | ++ if (!addedIds.isEmpty()) { |
| 72 | ++ List<FileAttachment> newAttachments = validateAndFindAttachments(addedIds); |
| 73 | ++ newAttachments.forEach(attachment -> |
| 74 | ++ attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.POST, post.getId())) |
| 75 | ++ ); |
| 76 | + } |
| 77 | +- attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); |
| 78 | ++ |
| 79 | ++ // 최신 매핑 다시 조회 |
| 80 | ++ return attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()) |
| 81 | ++ .stream() |
| 82 | ++ .map(AttachmentMapping::getFileAttachment) |
| 83 | ++ .toList(); |
| 84 | + } |
| 85 | + |
| 86 | + /** |
| 87 | +@@ -231,15 +226,7 @@ public class PostService { |
| 88 | + } |
| 89 | + |
| 90 | + // 첨부 파일 삭제 |
| 91 | +- List<AttachmentMapping> mappings = |
| 92 | +- attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); |
| 93 | +- for (AttachmentMapping mapping : mappings) { |
| 94 | +- FileAttachment fileAttachment = mapping.getFileAttachment(); |
| 95 | +- if (fileAttachment != null) { |
| 96 | +- fileService.deleteFile(fileAttachment.getId(), userId); |
| 97 | +- } |
| 98 | +- } |
| 99 | +- attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); |
| 100 | ++ attachmentMappingService.deleteAttachments(EntityType.POST, post.getId(), userId); |
| 101 | + |
| 102 | + // Post 삭제 |
| 103 | + post.remove(); |
| 104 | +diff --git a/src/main/java/com/back/domain/file/service/AttachmentMappingService.java b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java |
| 105 | +index 6b38b8d..12dbb0d 100644 |
| 106 | +--- a/src/main/java/com/back/domain/file/service/AttachmentMappingService.java |
| 107 | ++++ b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java |
| 108 | +@@ -109,6 +109,31 @@ public class AttachmentMappingService { |
| 109 | + attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(entityType, entityId); |
| 110 | + } |
| 111 | + |
| 112 | ++ /** |
| 113 | ++ * 기존 매핑 중 새 요청(newIds)에 없는 첨부만 삭제 |
| 114 | ++ * - S3 객체 삭제 |
| 115 | ++ * - 매핑 테이블 + 파일 정보 삭제 |
| 116 | ++ */ |
| 117 | ++ @Transactional |
| 118 | ++ public void deleteRemovedAttachments(EntityType entityType, Long entityId, Long userId, List<Long> newIds) { |
| 119 | ++ List<AttachmentMapping> mappings = |
| 120 | ++ attachmentMappingRepository.findAllByEntityTypeAndEntityId(entityType, entityId); |
| 121 | ++ |
| 122 | ++ for (AttachmentMapping mapping : mappings) { |
| 123 | ++ FileAttachment attachment = mapping.getFileAttachment(); |
| 124 | ++ |
| 125 | ++ if (attachment == null) continue; |
| 126 | ++ |
| 127 | ++ Long attachmentId = attachment.getId(); |
| 128 | ++ |
| 129 | ++ // 새 요청에 포함되지 않은 첨부만 삭제 |
| 130 | ++ if (!newIds.contains(attachmentId)) { |
| 131 | ++ s3Delete(attachment.getStoredName()); |
| 132 | ++ attachmentMappingRepository.delete(mapping); |
| 133 | ++ } |
| 134 | ++ } |
| 135 | ++ } |
| 136 | ++ |
| 137 | + private void s3Delete(String fileName) { |
| 138 | + amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); |
| 139 | + } |
| 140 | +diff --git a/src/main/java/com/back/domain/user/account/service/AccountService.java b/src/main/java/com/back/domain/user/account/service/AccountService.java |
| 141 | +index 268af79..d07eba8 100644 |
| 142 | +--- a/src/main/java/com/back/domain/user/account/service/AccountService.java |
| 143 | ++++ b/src/main/java/com/back/domain/user/account/service/AccountService.java |
| 144 | +@@ -5,12 +5,8 @@ import com.back.domain.board.comment.repository.CommentRepository; |
| 145 | + import com.back.domain.board.common.dto.PageResponse; |
| 146 | + import com.back.domain.board.post.dto.PostListResponse; |
| 147 | + import com.back.domain.board.post.repository.PostRepository; |
| 148 | +-import com.back.domain.file.entity.AttachmentMapping; |
| 149 | + import com.back.domain.file.entity.EntityType; |
| 150 | +-import com.back.domain.file.entity.FileAttachment; |
| 151 | +-import com.back.domain.file.repository.AttachmentMappingRepository; |
| 152 | +-import com.back.domain.file.repository.FileAttachmentRepository; |
| 153 | +-import com.back.domain.file.service.FileService; |
| 154 | ++import com.back.domain.file.service.AttachmentMappingService; |
| 155 | + import com.back.domain.user.account.dto.ChangePasswordRequest; |
| 156 | + import com.back.domain.user.account.dto.UserProfileRequest; |
| 157 | + import com.back.domain.user.account.dto.UserDetailResponse; |
| 158 | +@@ -40,9 +36,7 @@ public class AccountService { |
| 159 | + private final UserProfileRepository userProfileRepository; |
| 160 | + private final CommentRepository commentRepository; |
| 161 | + private final PostRepository postRepository; |
| 162 | +- private final FileAttachmentRepository fileAttachmentRepository; |
| 163 | +- private final AttachmentMappingRepository attachmentMappingRepository; |
| 164 | +- private final FileService fileService; |
| 165 | ++ private final AttachmentMappingService attachmentMappingService; |
| 166 | + private final PasswordEncoder passwordEncoder; |
| 167 | + |
| 168 | + /** |
| 169 | +@@ -76,25 +70,17 @@ public class AccountService { |
| 170 | + throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); |
| 171 | + } |
| 172 | + |
| 173 | +- |
| 174 | + // UserProfile 업데이트 |
| 175 | + UserProfile profile = user.getUserProfile(); |
| 176 | + profile.setNickname(request.nickname()); |
| 177 | + profile.setBio(request.bio()); |
| 178 | + profile.setBirthDate(request.birthDate()); |
| 179 | + |
| 180 | +- // TODO: 프로필 이미지 및 매핑 업데이트 리팩토링 필요 |
| 181 | + // 프로필 이미지 변경이 있는 경우만 수행 |
| 182 | + String newUrl = request.profileImageUrl(); |
| 183 | + String oldUrl = profile.getProfileImageUrl(); |
| 184 | + if (!Objects.equals(newUrl, oldUrl)) { |
| 185 | +- // 외부 이미지(S3 외부 URL)는 매핑 로직 제외 |
| 186 | +- if (isExternalImageUrl(newUrl)) { |
| 187 | +- // 기존 매핑만 제거 (소셜 이미지로 바뀌면 내부 매핑 필요 없음) |
| 188 | +- removeExistingMapping(userId); |
| 189 | +- } else { |
| 190 | +- updateProfileImage(userId, newUrl); |
| 191 | +- } |
| 192 | ++ attachmentMappingService.replaceAttachmentByUrl(EntityType.PROFILE, profile.getId(), userId, newUrl); |
| 193 | + profile.setProfileImageUrl(newUrl); |
| 194 | + } |
| 195 | + |
| 196 | +@@ -102,61 +88,6 @@ public class AccountService { |
| 197 | + return UserDetailResponse.from(user); |
| 198 | + } |
| 199 | + |
| 200 | +- /** |
| 201 | +- * 내부 저장소(S3) 이미지 교체 로직 |
| 202 | +- * - 기존 매핑 및 파일 삭제 후 새 매핑 생성 |
| 203 | +- */ |
| 204 | +- private void updateProfileImage(Long userId, String newImageUrl) { |
| 205 | +- |
| 206 | +- // 기존 매핑 제거 |
| 207 | +- removeExistingMapping(userId); |
| 208 | +- |
| 209 | +- // 새 이미지가 없는 경우 |
| 210 | +- if (newImageUrl == null || newImageUrl.isBlank()) { |
| 211 | +- return; |
| 212 | +- } |
| 213 | +- |
| 214 | +- // 새 파일 조회 및 검증 |
| 215 | +- FileAttachment newAttachment = fileAttachmentRepository |
| 216 | +- .findByPublicURL(newImageUrl) |
| 217 | +- .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); |
| 218 | +- |
| 219 | +- if (!newAttachment.getUser().getId().equals(userId)) { |
| 220 | +- throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); |
| 221 | +- } |
| 222 | +- |
| 223 | +- // 새 매핑 생성 및 저장 |
| 224 | +- AttachmentMapping newMapping = new AttachmentMapping(newAttachment, EntityType.PROFILE, userId); |
| 225 | +- attachmentMappingRepository.save(newMapping); |
| 226 | +- } |
| 227 | +- |
| 228 | +- /** |
| 229 | +- * 기존 프로필 이미지 매핑 및 파일 삭제 |
| 230 | +- */ |
| 231 | +- private void removeExistingMapping(Long userId) { |
| 232 | +- attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, userId) |
| 233 | +- .ifPresent(mapping -> { |
| 234 | +- FileAttachment oldAttachment = mapping.getFileAttachment(); |
| 235 | +- if (oldAttachment != null) { |
| 236 | +- fileService.deleteFile(oldAttachment.getId(), userId); |
| 237 | +- } |
| 238 | +- attachmentMappingRepository.delete(mapping); |
| 239 | +- }); |
| 240 | +- } |
| 241 | +- |
| 242 | +- /** |
| 243 | +- * 외부 이미지 URL 판별 |
| 244 | +- * - 우리 S3 또는 CDN이 아니면 true |
| 245 | +- * - 필요 시 application.yml에서 환경변수로 관리 |
| 246 | +- */ |
| 247 | +- private boolean isExternalImageUrl(String url) { |
| 248 | +- if (url == null || url.isBlank()) return true; |
| 249 | +- |
| 250 | +- // TODO: 하드 코딩 제거 |
| 251 | +- return !(url.startsWith("https://team5-s3-1.s3.ap-northeast-2.amazonaws.com") |
| 252 | +- || url.contains("cdn.example.com")); |
| 253 | +- } |
| 254 | +- |
| 255 | + /** |
| 256 | + * 비밀번호 변경 서비스 |
| 257 | + * 1. 사용자 조회 및 상태 검증 |
| 258 | +@@ -196,9 +127,6 @@ public class AccountService { |
| 259 | + // 사용자 조회 및 상태 검증 |
| 260 | + User user = getValidUser(userId); |
| 261 | + |
| 262 | +- // 프로필 이미지 및 매핑 삭제 |
| 263 | +- removeExistingMapping(userId); |
| 264 | +- |
| 265 | + // 상태 변경 (soft delete) |
| 266 | + user.setUserStatus(UserStatus.DELETED); |
| 267 | + |
| 268 | +@@ -211,6 +139,9 @@ public class AccountService { |
| 269 | + // 개인정보 마스킹 |
| 270 | + UserProfile profile = user.getUserProfile(); |
| 271 | + if (profile != null) { |
| 272 | ++ // 프로필 이미지 및 매핑 삭제 |
| 273 | ++ attachmentMappingService.deleteAttachments(EntityType.PROFILE, profile.getId(), userId); |
| 274 | ++ |
| 275 | + profile.setNickname("탈퇴한 회원"); |
| 276 | + profile.setProfileImageUrl(null); |
| 277 | + profile.setBio(null); |
| 278 | +diff --git a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java |
| 279 | +index 5b16103..ab9b3aa 100644 |
| 280 | +--- a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java |
| 281 | ++++ b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java |
| 282 | +@@ -1,5 +1,6 @@ |
| 283 | + package com.back.domain.board.post.service; |
| 284 | + |
| 285 | ++import com.amazonaws.services.s3.AmazonS3; |
| 286 | + import com.back.domain.board.common.dto.PageResponse; |
| 287 | + import com.back.domain.board.post.entity.Post; |
| 288 | + import com.back.domain.board.post.entity.PostCategory; |
| 289 | +@@ -15,7 +16,7 @@ import com.back.domain.file.entity.EntityType; |
| 290 | + import com.back.domain.file.entity.FileAttachment; |
| 291 | + import com.back.domain.file.repository.AttachmentMappingRepository; |
| 292 | + import com.back.domain.file.repository.FileAttachmentRepository; |
| 293 | +-import com.back.domain.file.service.FileService; |
| 294 | ++import com.back.domain.file.service.AttachmentMappingService; |
| 295 | + import com.back.domain.user.common.entity.User; |
| 296 | + import com.back.domain.user.common.entity.UserProfile; |
| 297 | + import com.back.domain.user.common.enums.UserStatus; |
| 298 | +@@ -26,6 +27,7 @@ import org.junit.jupiter.api.DisplayName; |
| 299 | + import org.junit.jupiter.api.Test; |
| 300 | + import org.springframework.beans.factory.annotation.Autowired; |
| 301 | + import org.springframework.boot.test.context.SpringBootTest; |
| 302 | ++import org.springframework.boot.test.mock.mockito.MockBean; |
| 303 | + import org.springframework.data.domain.PageRequest; |
| 304 | + import org.springframework.data.domain.Pageable; |
| 305 | + import org.springframework.data.domain.Sort; |
| 306 | +@@ -61,8 +63,11 @@ class PostServiceTest { |
| 307 | + @Autowired |
| 308 | + private AttachmentMappingRepository attachmentMappingRepository; |
| 309 | + |
| 310 | +- @MockitoBean |
| 311 | +- private FileService fileService; |
| 312 | ++ @Autowired |
| 313 | ++ private AttachmentMappingService attachmentMappingService; |
| 314 | ++ |
| 315 | ++ @MockBean |
| 316 | ++ private AmazonS3 amazonS3; // S3 호출 차단용 mock |
| 317 | + |
| 318 | + // ====================== 게시글 생성 테스트 ====================== |
| 319 | + |
| 320 | +diff --git a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java |
| 321 | +index ab94f70..e0ec982 100644 |
| 322 | +--- a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java |
| 323 | ++++ b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java |
| 324 | +@@ -1,5 +1,6 @@ |
| 325 | + package com.back.domain.user.account.service; |
| 326 | + |
| 327 | ++import com.amazonaws.services.s3.AmazonS3; |
| 328 | + import com.back.domain.board.comment.dto.MyCommentResponse; |
| 329 | + import com.back.domain.board.comment.entity.Comment; |
| 330 | + import com.back.domain.board.comment.repository.CommentRepository; |
| 331 | +@@ -14,7 +15,7 @@ import com.back.domain.file.entity.EntityType; |
| 332 | + import com.back.domain.file.entity.FileAttachment; |
| 333 | + import com.back.domain.file.repository.AttachmentMappingRepository; |
| 334 | + import com.back.domain.file.repository.FileAttachmentRepository; |
| 335 | +-import com.back.domain.file.service.FileService; |
| 336 | ++import com.back.domain.file.service.AttachmentMappingService; |
| 337 | + import com.back.domain.user.account.dto.ChangePasswordRequest; |
| 338 | + import com.back.domain.user.account.dto.UserProfileRequest; |
| 339 | + import com.back.domain.user.account.dto.UserDetailResponse; |
| 340 | +@@ -28,6 +29,7 @@ import org.junit.jupiter.api.DisplayName; |
| 341 | + import org.junit.jupiter.api.Test; |
| 342 | + import org.springframework.beans.factory.annotation.Autowired; |
| 343 | + import org.springframework.boot.test.context.SpringBootTest; |
| 344 | ++import org.springframework.boot.test.mock.mockito.MockBean; |
| 345 | + import org.springframework.data.domain.PageRequest; |
| 346 | + import org.springframework.data.domain.Pageable; |
| 347 | + import org.springframework.data.domain.Sort; |
| 348 | +@@ -73,8 +75,11 @@ class AccountServiceTest { |
| 349 | + @Autowired |
| 350 | + private PasswordEncoder passwordEncoder; |
| 351 | + |
| 352 | +- @MockitoBean |
| 353 | +- private FileService fileService; |
| 354 | ++ @Autowired |
| 355 | ++ private AttachmentMappingService attachmentMappingService; |
| 356 | ++ |
| 357 | ++ @MockBean |
| 358 | ++ private AmazonS3 amazonS3; // S3 호출 차단용 mock |
| 359 | + |
| 360 | + private MultipartFile mockMultipartFile(String filename) { |
| 361 | + return new MockMultipartFile(filename, filename, "image/png", new byte[]{1, 2, 3}); |
| 362 | +@@ -169,7 +174,7 @@ class AccountServiceTest { |
| 363 | + assertThat(response.profile().nickname()).isEqualTo("새닉네임"); |
| 364 | + |
| 365 | + // 새 매핑이 존재하고 기존 매핑은 삭제되었는지 검증 |
| 366 | +- List<AttachmentMapping> mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.PROFILE, user.getId()); |
| 367 | ++ List<AttachmentMapping> mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.PROFILE, user.getUserProfile().getId()); |
| 368 | + assertThat(mappings).hasSize(1); |
| 369 | + assertThat(mappings.get(0).getFileAttachment().getPublicURL()).isEqualTo(newAttachment.getPublicURL()); |
| 370 | + |
| 371 | +@@ -364,7 +369,7 @@ class AccountServiceTest { |
| 372 | + // 프로필 이미지 매핑 설정 |
| 373 | + FileAttachment attachment = new FileAttachment("profile_uuid_img.png", mockMultipartFile("profile.png"), user, "https://cdn.example.com/profile.png"); |
| 374 | + fileAttachmentRepository.save(attachment); |
| 375 | +- attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.PROFILE, user.getId())); |
| 376 | ++ attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.PROFILE, user.getUserProfile().getId())); |
| 377 | + |
| 378 | + // when: 탈퇴 처리 |
| 379 | + accountService.deleteUser(user.getId()); |
| 380 | +@@ -385,7 +390,7 @@ class AccountServiceTest { |
| 381 | + assertThat(profile.getBirthDate()).isNull(); |
| 382 | + |
| 383 | + // 프로필 이미지 및 매핑 삭제 검증 |
| 384 | +- assertThat(attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, user.getId())).isEmpty(); |
| 385 | ++ assertThat(attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, user.getUserProfile().getId())).isEmpty(); |
| 386 | + assertThat(fileAttachmentRepository.findByPublicURL("https://cdn.example.com/profile.png")).isEmpty(); |
| 387 | + } |
| 388 | + |
0 commit comments