From f0aa092dda387d11e77cd1e9e0200042b38aae96 Mon Sep 17 00:00:00 2001 From: loseminho Date: Thu, 16 Oct 2025 10:56:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Hotfix/loseminho=20:=20terminated=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?(#314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 스더티룸 권한에 대한 로직 개선 * fix: ci에서 통과 못한 테스트코드 수정 * fix:rest api와 웹소켓 중간 경로 통합 * fix:rest api와 웹소켓 중간 경로 통합 * fix: 에러 확인을 위한 통합테스트 추가, Room.create()메서드 수정 * refactor, feat : 조회 분할 * refactor: redis 로직 최적화 및 중복 검증 로직 제거 * fix: 에러 번호 수정 * feat: 스터디룸 방 비밀번호 변경 및 삭제 기능 구현 * fix:app-dev 제거 * feat: 웹소켓 기반 소극적 하트비트 * feat: 스터디룸 썸네일 기능 추가 및 webrtc 설정 변경에서 주석처리 * fix:소극적 하트비트 사용 주석처리 * Feat: 스터디 룸 내에 고양이 아바타 시스템과 프로필 이미지 url 연동 * fix: 기존 작성되어있던 test 코드 수정 * test: 아바타 테스트 코드 완료 * refactor: 프론트엔드 요청 사항에 따른 스터디룸 조회 마스킹 제거 * feat: 스터디룸 방 초대 코드 시스템 * Infra: main branch 로컬 환경과 운영 환경 동기화 * Infra: docker-compose 파일 수정 - Redis 버전 업그레이드 기존: 6.2 -> 변경: 7.0 * Fix: 백엔드 CD 파일 수정 - 자동화 시, 잘못된 도메인으로 호스트 ID 검증하는 오류 해결 * Infra: EC2 환경변수 수정 - 잘못 표기한 도메인 네임 변경 * Chore: CD 파일 수정 - Github Actions commandLine 인식 문제로 인해 set -Eeuo pipefail 줄바꿈 * Chore: 백엔드 CD 파일 수정 - 인스턴스 ID 체크 삭제 * Infra: 백엔드 CD 파일 수정 - .env 파일 추가시, $DOT_ENV_PROD -> $DOT_ENV 로 변경 * Infra: 도커 컴포즈 수정 - mysql 사용자 정보 변경 * Infra: 운영환경 설정 - application-prod.yml 과 application.yml 동기화 * Fix: SecurityConfig 수정 - H2 DB 허용 X * test,fix: 방 초대에 대한 테스트 코드 작성 및 에러 수정 * fix: 스터디룸 파일 업로드 맵핑 형식으로 변환 * fix: 병합충돌 제어 수정 * fix: 병합충돌 제어 * fix: 스터디 룸 내 프론트엔드 요구 사항 및 오류사항 수정 * feat: 방 즐겨찾기, 방 공지사항 구현 * fix: mockbean 수정 * fix: 테스트에서 빠진 비로그인 사용자 추가 * hotfix: 누락된 사용자 추방에 대한 컨트롤러 추가 * hotfix: VISITOR도 추방 가능하도록 수정 * fix: 누락된 테스트코드 추가 및 테스트코드 로직 수정 * refactor: 아바타 시스템 db와 분리 및 테스트 코드 수정 * fix: 턴서버 dev에 맞춤 * hotfix: 추방 후 추방당한 유저에게 개인 메시지 전송 로직 추가 * fix: 웹소켓 메세지 전송 * fix: 병합 오류 제어 * test: 테스트코드 수정 * refactor: 스터디룸 파일 업로드 s3 + fileAttachment + Mapping 제거 방식으로 수정 * feat: 방 내 방명록 기능 추가 * hotfix:로젝 내 웹소켓 세션 제거 * hotfix: 방 조회 시 terminated 상태는 조회 안되도록 --------- Co-authored-by: namgigun --- .../repository/RoomRepositoryImpl.java | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java index 77e38e99..96b36ce7 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -33,12 +33,12 @@ public class RoomRepositoryImpl implements RoomRepositoryCustom { private final QUser user = QUser.user; /** - * 공개 방 중 입장 가능한 방들 조회 (페이징) + * 공개 방 중 입장 가능한 방들 조회 (페이징, TERMINATED 제외) * 조회 조건: * - 비공개가 아닌 방 (isPrivate = false) * - 활성화된 방 (isActive = true) * - 입장 가능한 상태 (WAITING 또는 ACTIVE) - * + * * 참고: 정원 체크는 Redis 기반으로 프론트엔드/서비스에서 수행 * @param pageable 페이징 정보 * @return 페이징된 방 목록 @@ -52,7 +52,8 @@ public Page findJoinablePublicRooms(Pageable pageable) { .where( room.isPrivate.eq(false), room.isActive.eq(true), - room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE), // WAITING, ACTIVE만 (TERMINATED, PAUSED 제외) + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 명시적 제외 ) .orderBy(room.createdAt.desc()) .offset(pageable.getOffset()) @@ -66,7 +67,8 @@ public Page findJoinablePublicRooms(Pageable pageable) { .where( room.isPrivate.eq(false), room.isActive.eq(true), - room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE), + room.status.ne(RoomStatus.TERMINATED) ) .fetchOne(); @@ -74,7 +76,7 @@ public Page findJoinablePublicRooms(Pageable pageable) { } /** - * 사용자가 참여 중인 방 조회 (MEMBER 이상만) + * 사용자가 참여 중인 방 조회 (MEMBER 이상만, TERMINATED 제외) * 조회 조건: * - 특정 사용자가 MEMBER 이상으로 등록된 방 (VISITOR 제외) * - DB에 저장된 멤버십만 조회 @@ -89,7 +91,8 @@ public List findRoomsByUserId(Long userId) { .join(room.roomMembers, roomMember) // 멤버 조인 .where( roomMember.user.id.eq(userId), - roomMember.role.ne(com.back.domain.studyroom.entity.RoomRole.VISITOR) // VISITOR 제외 + roomMember.role.ne(com.back.domain.studyroom.entity.RoomRole.VISITOR), // VISITOR 제외 + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 제외 ) .fetch(); } @@ -160,7 +163,7 @@ public Page findRoomsWithFilters(String title, RoomStatus status, Boolean } /** - * 인기 방 조회 (참가자 수 기준) - 공개+비공개 포함 + * 인기 방 조회 (참가자 수 기준, TERMINATED 제외) * * 참고: 참가자 수는 Redis에서 조회하므로 DB에서는 정렬 불가 * 서비스 레이어에서 Redis 데이터로 정렬 필요 @@ -177,7 +180,8 @@ public Page findPopularRooms(Pageable pageable) { .selectFrom(room) .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 .where( - room.isActive.eq(true) + room.isActive.eq(true), + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 제외 ) .orderBy(room.createdAt.desc()) // 최신순 (서비스에서 Redis 기반으로 재정렬) .offset(pageable.getOffset()) @@ -189,7 +193,8 @@ public Page findPopularRooms(Pageable pageable) { .select(room.count()) .from(room) .where( - room.isActive.eq(true) + room.isActive.eq(true), + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 제외 ) .fetchOne(); @@ -272,14 +277,14 @@ public Optional findByIdWithLock(Long roomId) { } /** - * 모든 방 조회 (공개 + 비공개 전체) + * 모든 방 조회 (공개 + 비공개 전체, TERMINATED 제외) * 조회 조건: * - 모든 방 (공개 + 비공개) * 정렬: * 1. 열린 방(WAITING, ACTIVE) 우선 * 2. 닫힌 방(PAUSED, TERMINATED) 뒤로 * 3. 최신 생성순 - * + * * 비공개 방은 컨트롤러/서비스 레이어에서 정보 마스킹 합니당 */ @Override @@ -287,8 +292,11 @@ public Page findAllRooms(Pageable pageable) { List rooms = queryFactory .selectFrom(room) .leftJoin(room.createdBy, user).fetchJoin() + .where( + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 제외 + ) .orderBy( - // 열린 방 우선 (0), 닫힌 방 뒤로 (1) + // 열린 방 우선 (0), 일시정지 방 뒤로 (1) room.status.when(RoomStatus.WAITING).then(0) .when(RoomStatus.ACTIVE).then(0) .otherwise(1).asc(), @@ -302,13 +310,16 @@ public Page findAllRooms(Pageable pageable) { Long totalCount = queryFactory .select(room.count()) .from(room) + .where( + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 제외 + ) .fetchOne(); return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); } /** - * 공개 방 전체 조회 + * 공개 방 전체 조회 (TERMINATED 제외) * 조회 조건: * - isPrivate = false * - includeInactive에 따라 닫힌 방 포함 여부 결정 @@ -316,9 +327,10 @@ public Page findAllRooms(Pageable pageable) { */ @Override public Page findPublicRoomsWithStatus(boolean includeInactive, Pageable pageable) { - BooleanExpression whereClause = room.isPrivate.eq(false); + BooleanExpression whereClause = room.isPrivate.eq(false) + .and(room.status.ne(RoomStatus.TERMINATED)); // TERMINATED 제외 - // 닫힌 방 제외 옵션 + // 일시정지 방 제외 옵션 if (!includeInactive) { whereClause = whereClause.and( room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) @@ -349,7 +361,7 @@ public Page findPublicRoomsWithStatus(boolean includeInactive, Pageable pa } /** - * 내가 멤버인 비공개 방 조회 + * 내가 멤버인 비공개 방 조회 (TERMINATED 제외) * 조회 조건: * - isPrivate = true * - 내가 멤버로 등록된 방 @@ -359,9 +371,10 @@ public Page findPublicRoomsWithStatus(boolean includeInactive, Pageable pa @Override public Page findMyPrivateRooms(Long userId, boolean includeInactive, Pageable pageable) { BooleanExpression whereClause = room.isPrivate.eq(true) - .and(roomMember.user.id.eq(userId)); + .and(roomMember.user.id.eq(userId)) + .and(room.status.ne(RoomStatus.TERMINATED)); // TERMINATED 제외 - // 닫힌 방 제외 옵션 + // 일시정지 방 제외 옵션 if (!includeInactive) { whereClause = whereClause.and( room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) @@ -394,7 +407,7 @@ public Page findMyPrivateRooms(Long userId, boolean includeInactive, Pagea } /** - * 내가 호스트(방장)인 방 조회 + * 내가 호스트(방장)인 방 조회 (TERMINATED 제외) * 조회 조건: * - room.createdBy.id = userId * 정렬: 열린 방 우선 → 최신순 @@ -404,7 +417,10 @@ public Page findRoomsByHostId(Long userId, Pageable pageable) { List rooms = queryFactory .selectFrom(room) .leftJoin(room.createdBy, user).fetchJoin() - .where(room.createdBy.id.eq(userId)) + .where( + room.createdBy.id.eq(userId), + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 제외 + ) .orderBy( room.status.when(RoomStatus.WAITING).then(0) .when(RoomStatus.ACTIVE).then(0) @@ -418,7 +434,10 @@ public Page findRoomsByHostId(Long userId, Pageable pageable) { Long totalCount = queryFactory .select(room.count()) .from(room) - .where(room.createdBy.id.eq(userId)) + .where( + room.createdBy.id.eq(userId), + room.status.ne(RoomStatus.TERMINATED) // TERMINATED 제외 + ) .fetchOne(); return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); From 07a95d3546e243001144857f38f17ac76c059f7e Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:05:41 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Refactor:=20PostService=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20(#298)=20(#316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ref: PostService 개선 * Test: 테스트 작성 --- commits.txt | 388 ------------------ .../back/domain/board/post/entity/Post.java | 21 +- .../board/post/entity/PostCategory.java | 4 + .../post/entity/PostCategoryMapping.java | 7 + .../PostCategoryMappingRepository.java | 9 + .../repository/PostCategoryRepository.java | 6 +- .../service/PostCategoryMappingService.java | 103 +++++ .../board/post/service/PostService.java | 99 +---- .../AttachmentMappingRepository.java | 6 + .../service/AttachmentMappingService.java | 146 ++++++- .../back/global/initData/DevInitService.java | 25 +- .../custom/CommentRepositoryImplTest.java | 6 +- .../post/controller/PostControllerTest.java | 15 +- .../custom/PostRepositoryImplTest.java | 53 ++- .../board/post/service/PostServiceTest.java | 21 +- .../service/AttachmentMappingServiceTest.java | 91 ++++ .../account/service/AccountServiceTest.java | 18 +- 17 files changed, 438 insertions(+), 580 deletions(-) delete mode 100644 commits.txt create mode 100644 src/main/java/com/back/domain/board/post/repository/PostCategoryMappingRepository.java create mode 100644 src/main/java/com/back/domain/board/post/service/PostCategoryMappingService.java diff --git a/commits.txt b/commits.txt deleted file mode 100644 index 87374823..00000000 --- a/commits.txt +++ /dev/null @@ -1,388 +0,0 @@ -commit f76663028be113cb49f058a173729621d0744a78 -Author: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> -Date: Thu Oct 16 01:16:42 2025 +0900 - - Fix: 게시글 및 프로필 파일 관련 로직 보완 - -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 -index becb576..4869b37 100644 ---- a/src/main/java/com/back/domain/board/post/service/PostService.java -+++ b/src/main/java/com/back/domain/board/post/service/PostService.java -@@ -17,7 +17,7 @@ import com.back.domain.file.entity.EntityType; - import com.back.domain.file.entity.FileAttachment; - import com.back.domain.file.repository.AttachmentMappingRepository; - import com.back.domain.file.repository.FileAttachmentRepository; --import com.back.domain.file.service.FileService; -+import com.back.domain.file.service.AttachmentMappingService; - import com.back.domain.user.common.entity.User; - import com.back.domain.user.common.repository.UserRepository; - import com.back.global.exception.CustomException; -@@ -41,7 +41,7 @@ public class PostService { - private final PostCategoryRepository postCategoryRepository; - private final FileAttachmentRepository fileAttachmentRepository; - private final AttachmentMappingRepository attachmentMappingRepository; -- private final FileService fileService; -+ private final AttachmentMappingService attachmentMappingService; - - /** - * 게시글 생성 서비스 -@@ -169,6 +169,7 @@ public class PostService { - private List updatePostAttachments(Post post, List newImageIds, Long userId) { - List newIds = (newImageIds != null) ? newImageIds : List.of(); - -+ // 기존 매핑 조회 - List existingMappings = - attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); - List existingIds = existingMappings.stream() -@@ -182,32 +183,26 @@ public class PostService { - .toList(); - } - -- // 기존 첨부 삭제 -- deletePostAttachments(post, userId); -+ // 기존 중 newIds에 없는 첨부만 삭제 -+ attachmentMappingService.deleteRemovedAttachments(EntityType.POST, post.getId(), userId, newIds); - -- // 새 첨부 매핑 등록 -- if (newIds.isEmpty()) return List.of(); -- -- List attachments = validateAndFindAttachments(newIds); -- attachments.forEach(attachment -> -- attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.POST, post.getId())) -- ); -- return attachments; -- } -+ // 새로 추가된 첨부만 매핑 생성 -+ List addedIds = newIds.stream() -+ .filter(id -> !existingIds.contains(id)) -+ .toList(); - -- /** -- * 게시글 첨부파일 삭제 (S3 + 매핑) -- */ -- private void deletePostAttachments(Post post, Long userId) { -- List mappings = -- attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); -- for (AttachmentMapping mapping : mappings) { -- FileAttachment file = mapping.getFileAttachment(); -- if (file != null) { -- fileService.deleteFile(file.getId(), userId); -- } -+ if (!addedIds.isEmpty()) { -+ List newAttachments = validateAndFindAttachments(addedIds); -+ newAttachments.forEach(attachment -> -+ attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.POST, post.getId())) -+ ); - } -- attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); -+ -+ // 최신 매핑 다시 조회 -+ return attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()) -+ .stream() -+ .map(AttachmentMapping::getFileAttachment) -+ .toList(); - } - - /** -@@ -231,15 +226,7 @@ public class PostService { - } - - // 첨부 파일 삭제 -- List mappings = -- attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); -- for (AttachmentMapping mapping : mappings) { -- FileAttachment fileAttachment = mapping.getFileAttachment(); -- if (fileAttachment != null) { -- fileService.deleteFile(fileAttachment.getId(), userId); -- } -- } -- attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); -+ attachmentMappingService.deleteAttachments(EntityType.POST, post.getId(), userId); - - // Post 삭제 - post.remove(); -diff --git a/src/main/java/com/back/domain/file/service/AttachmentMappingService.java b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java -index 6b38b8d..12dbb0d 100644 ---- a/src/main/java/com/back/domain/file/service/AttachmentMappingService.java -+++ b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java -@@ -109,6 +109,31 @@ public class AttachmentMappingService { - attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(entityType, entityId); - } - -+ /** -+ * 기존 매핑 중 새 요청(newIds)에 없는 첨부만 삭제 -+ * - S3 객체 삭제 -+ * - 매핑 테이블 + 파일 정보 삭제 -+ */ -+ @Transactional -+ public void deleteRemovedAttachments(EntityType entityType, Long entityId, Long userId, List newIds) { -+ List mappings = -+ attachmentMappingRepository.findAllByEntityTypeAndEntityId(entityType, entityId); -+ -+ for (AttachmentMapping mapping : mappings) { -+ FileAttachment attachment = mapping.getFileAttachment(); -+ -+ if (attachment == null) continue; -+ -+ Long attachmentId = attachment.getId(); -+ -+ // 새 요청에 포함되지 않은 첨부만 삭제 -+ if (!newIds.contains(attachmentId)) { -+ s3Delete(attachment.getStoredName()); -+ attachmentMappingRepository.delete(mapping); -+ } -+ } -+ } -+ - private void s3Delete(String fileName) { - amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); - } -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 -index 268af79..d07eba8 100644 ---- a/src/main/java/com/back/domain/user/account/service/AccountService.java -+++ b/src/main/java/com/back/domain/user/account/service/AccountService.java -@@ -5,12 +5,8 @@ import com.back.domain.board.comment.repository.CommentRepository; - import com.back.domain.board.common.dto.PageResponse; - import com.back.domain.board.post.dto.PostListResponse; - import com.back.domain.board.post.repository.PostRepository; --import com.back.domain.file.entity.AttachmentMapping; - import com.back.domain.file.entity.EntityType; --import com.back.domain.file.entity.FileAttachment; --import com.back.domain.file.repository.AttachmentMappingRepository; --import com.back.domain.file.repository.FileAttachmentRepository; --import com.back.domain.file.service.FileService; -+import com.back.domain.file.service.AttachmentMappingService; - import com.back.domain.user.account.dto.ChangePasswordRequest; - import com.back.domain.user.account.dto.UserProfileRequest; - import com.back.domain.user.account.dto.UserDetailResponse; -@@ -40,9 +36,7 @@ public class AccountService { - private final UserProfileRepository userProfileRepository; - private final CommentRepository commentRepository; - private final PostRepository postRepository; -- private final FileAttachmentRepository fileAttachmentRepository; -- private final AttachmentMappingRepository attachmentMappingRepository; -- private final FileService fileService; -+ private final AttachmentMappingService attachmentMappingService; - private final PasswordEncoder passwordEncoder; - - /** -@@ -76,25 +70,17 @@ public class AccountService { - throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); - } - -- - // UserProfile 업데이트 - UserProfile profile = user.getUserProfile(); - profile.setNickname(request.nickname()); - profile.setBio(request.bio()); - profile.setBirthDate(request.birthDate()); - -- // TODO: 프로필 이미지 및 매핑 업데이트 리팩토링 필요 - // 프로필 이미지 변경이 있는 경우만 수행 - String newUrl = request.profileImageUrl(); - String oldUrl = profile.getProfileImageUrl(); - if (!Objects.equals(newUrl, oldUrl)) { -- // 외부 이미지(S3 외부 URL)는 매핑 로직 제외 -- if (isExternalImageUrl(newUrl)) { -- // 기존 매핑만 제거 (소셜 이미지로 바뀌면 내부 매핑 필요 없음) -- removeExistingMapping(userId); -- } else { -- updateProfileImage(userId, newUrl); -- } -+ attachmentMappingService.replaceAttachmentByUrl(EntityType.PROFILE, profile.getId(), userId, newUrl); - profile.setProfileImageUrl(newUrl); - } - -@@ -102,61 +88,6 @@ public class AccountService { - return UserDetailResponse.from(user); - } - -- /** -- * 내부 저장소(S3) 이미지 교체 로직 -- * - 기존 매핑 및 파일 삭제 후 새 매핑 생성 -- */ -- private void updateProfileImage(Long userId, String newImageUrl) { -- -- // 기존 매핑 제거 -- removeExistingMapping(userId); -- -- // 새 이미지가 없는 경우 -- if (newImageUrl == null || newImageUrl.isBlank()) { -- return; -- } -- -- // 새 파일 조회 및 검증 -- FileAttachment newAttachment = fileAttachmentRepository -- .findByPublicURL(newImageUrl) -- .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); -- -- if (!newAttachment.getUser().getId().equals(userId)) { -- throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); -- } -- -- // 새 매핑 생성 및 저장 -- AttachmentMapping newMapping = new AttachmentMapping(newAttachment, EntityType.PROFILE, userId); -- attachmentMappingRepository.save(newMapping); -- } -- -- /** -- * 기존 프로필 이미지 매핑 및 파일 삭제 -- */ -- private void removeExistingMapping(Long userId) { -- attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, userId) -- .ifPresent(mapping -> { -- FileAttachment oldAttachment = mapping.getFileAttachment(); -- if (oldAttachment != null) { -- fileService.deleteFile(oldAttachment.getId(), userId); -- } -- attachmentMappingRepository.delete(mapping); -- }); -- } -- -- /** -- * 외부 이미지 URL 판별 -- * - 우리 S3 또는 CDN이 아니면 true -- * - 필요 시 application.yml에서 환경변수로 관리 -- */ -- private boolean isExternalImageUrl(String url) { -- if (url == null || url.isBlank()) return true; -- -- // TODO: 하드 코딩 제거 -- return !(url.startsWith("https://team5-s3-1.s3.ap-northeast-2.amazonaws.com") -- || url.contains("cdn.example.com")); -- } -- - /** - * 비밀번호 변경 서비스 - * 1. 사용자 조회 및 상태 검증 -@@ -196,9 +127,6 @@ public class AccountService { - // 사용자 조회 및 상태 검증 - User user = getValidUser(userId); - -- // 프로필 이미지 및 매핑 삭제 -- removeExistingMapping(userId); -- - // 상태 변경 (soft delete) - user.setUserStatus(UserStatus.DELETED); - -@@ -211,6 +139,9 @@ public class AccountService { - // 개인정보 마스킹 - UserProfile profile = user.getUserProfile(); - if (profile != null) { -+ // 프로필 이미지 및 매핑 삭제 -+ attachmentMappingService.deleteAttachments(EntityType.PROFILE, profile.getId(), userId); -+ - profile.setNickname("탈퇴한 회원"); - profile.setProfileImageUrl(null); - profile.setBio(null); -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 -index 5b16103..ab9b3aa 100644 ---- a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java -+++ b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java -@@ -1,5 +1,6 @@ - package com.back.domain.board.post.service; - -+import com.amazonaws.services.s3.AmazonS3; - import com.back.domain.board.common.dto.PageResponse; - import com.back.domain.board.post.entity.Post; - import com.back.domain.board.post.entity.PostCategory; -@@ -15,7 +16,7 @@ import com.back.domain.file.entity.EntityType; - import com.back.domain.file.entity.FileAttachment; - import com.back.domain.file.repository.AttachmentMappingRepository; - import com.back.domain.file.repository.FileAttachmentRepository; --import com.back.domain.file.service.FileService; -+import com.back.domain.file.service.AttachmentMappingService; - import com.back.domain.user.common.entity.User; - import com.back.domain.user.common.entity.UserProfile; - import com.back.domain.user.common.enums.UserStatus; -@@ -26,6 +27,7 @@ import org.junit.jupiter.api.DisplayName; - import org.junit.jupiter.api.Test; - import org.springframework.beans.factory.annotation.Autowired; - import org.springframework.boot.test.context.SpringBootTest; -+import org.springframework.boot.test.mock.mockito.MockBean; - import org.springframework.data.domain.PageRequest; - import org.springframework.data.domain.Pageable; - import org.springframework.data.domain.Sort; -@@ -61,8 +63,11 @@ class PostServiceTest { - @Autowired - private AttachmentMappingRepository attachmentMappingRepository; - -- @MockitoBean -- private FileService fileService; -+ @Autowired -+ private AttachmentMappingService attachmentMappingService; -+ -+ @MockBean -+ private AmazonS3 amazonS3; // S3 호출 차단용 mock - - // ====================== 게시글 생성 테스트 ====================== - -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 -index ab94f70..e0ec982 100644 ---- a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java -+++ b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java -@@ -1,5 +1,6 @@ - package com.back.domain.user.account.service; - -+import com.amazonaws.services.s3.AmazonS3; - import com.back.domain.board.comment.dto.MyCommentResponse; - import com.back.domain.board.comment.entity.Comment; - import com.back.domain.board.comment.repository.CommentRepository; -@@ -14,7 +15,7 @@ import com.back.domain.file.entity.EntityType; - import com.back.domain.file.entity.FileAttachment; - import com.back.domain.file.repository.AttachmentMappingRepository; - import com.back.domain.file.repository.FileAttachmentRepository; --import com.back.domain.file.service.FileService; -+import com.back.domain.file.service.AttachmentMappingService; - import com.back.domain.user.account.dto.ChangePasswordRequest; - import com.back.domain.user.account.dto.UserProfileRequest; - import com.back.domain.user.account.dto.UserDetailResponse; -@@ -28,6 +29,7 @@ import org.junit.jupiter.api.DisplayName; - import org.junit.jupiter.api.Test; - import org.springframework.beans.factory.annotation.Autowired; - import org.springframework.boot.test.context.SpringBootTest; -+import org.springframework.boot.test.mock.mockito.MockBean; - import org.springframework.data.domain.PageRequest; - import org.springframework.data.domain.Pageable; - import org.springframework.data.domain.Sort; -@@ -73,8 +75,11 @@ class AccountServiceTest { - @Autowired - private PasswordEncoder passwordEncoder; - -- @MockitoBean -- private FileService fileService; -+ @Autowired -+ private AttachmentMappingService attachmentMappingService; -+ -+ @MockBean -+ private AmazonS3 amazonS3; // S3 호출 차단용 mock - - private MultipartFile mockMultipartFile(String filename) { - return new MockMultipartFile(filename, filename, "image/png", new byte[]{1, 2, 3}); -@@ -169,7 +174,7 @@ class AccountServiceTest { - assertThat(response.profile().nickname()).isEqualTo("새닉네임"); - - // 새 매핑이 존재하고 기존 매핑은 삭제되었는지 검증 -- List mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.PROFILE, user.getId()); -+ List mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.PROFILE, user.getUserProfile().getId()); - assertThat(mappings).hasSize(1); - assertThat(mappings.get(0).getFileAttachment().getPublicURL()).isEqualTo(newAttachment.getPublicURL()); - -@@ -364,7 +369,7 @@ class AccountServiceTest { - // 프로필 이미지 매핑 설정 - FileAttachment attachment = new FileAttachment("profile_uuid_img.png", mockMultipartFile("profile.png"), user, "https://cdn.example.com/profile.png"); - fileAttachmentRepository.save(attachment); -- attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.PROFILE, user.getId())); -+ attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.PROFILE, user.getUserProfile().getId())); - - // when: 탈퇴 처리 - accountService.deleteUser(user.getId()); -@@ -385,7 +390,7 @@ class AccountServiceTest { - assertThat(profile.getBirthDate()).isNull(); - - // 프로필 이미지 및 매핑 삭제 검증 -- assertThat(attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, user.getId())).isEmpty(); -+ assertThat(attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.PROFILE, user.getUserProfile().getId())).isEmpty(); - assertThat(fileAttachmentRepository.findByPublicURL("https://cdn.example.com/profile.png")).isEmpty(); - } - diff --git a/src/main/java/com/back/domain/board/post/entity/Post.java b/src/main/java/com/back/domain/board/post/entity/Post.java index 270379c5..ccf6ed73 100644 --- a/src/main/java/com/back/domain/board/post/entity/Post.java +++ b/src/main/java/com/back/domain/board/post/entity/Post.java @@ -98,25 +98,6 @@ public void update(String title, String content, String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; } - /** 카테고리 일괄 업데이트 */ - public void updateCategories(List newCategories) { - List currentCategories = this.getCategories(); - - // 제거 대상 - List toRemove = this.getPostCategoryMappings().stream() - .filter(mapping -> !newCategories.contains(mapping.getCategory())) - .toList(); - - // 추가 대상 - List toAdd = newCategories.stream() - .filter(category -> !currentCategories.contains(category)) - .toList(); - - // 실행 - toRemove.forEach(this::removePostCategoryMapping); - toAdd.forEach(category -> new PostCategoryMapping(this, category)); - } - /** 좋아요 수 증가 */ public void increaseLikeCount() { this.likeCount++; @@ -156,7 +137,7 @@ public void decreaseCommentCount() { // -------------------- 헬퍼 메서드 -------------------- /** 게시글에 연결된 카테고리 목록 조회 */ public List getCategories() { - return postCategoryMappings.stream() + return this.postCategoryMappings.stream() .map(PostCategoryMapping::getCategory) .toList(); } diff --git a/src/main/java/com/back/domain/board/post/entity/PostCategory.java b/src/main/java/com/back/domain/board/post/entity/PostCategory.java index 807a52e6..f7ea7012 100644 --- a/src/main/java/com/back/domain/board/post/entity/PostCategory.java +++ b/src/main/java/com/back/domain/board/post/entity/PostCategory.java @@ -37,4 +37,8 @@ public PostCategory(String name, CategoryType type) { public void addPostCategoryMapping(PostCategoryMapping mapping) { this.postCategoryMappings.add(mapping); } + + public void removePostCategoryMapping(PostCategoryMapping mapping) { + this.postCategoryMappings.remove(mapping); + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/post/entity/PostCategoryMapping.java b/src/main/java/com/back/domain/board/post/entity/PostCategoryMapping.java index 6d0a60f8..23e18cb5 100644 --- a/src/main/java/com/back/domain/board/post/entity/PostCategoryMapping.java +++ b/src/main/java/com/back/domain/board/post/entity/PostCategoryMapping.java @@ -30,4 +30,11 @@ public PostCategoryMapping(Post post, PostCategory category) { post.addPostCategoryMapping(this); category.addPostCategoryMapping(this); } + + // -------------------- 헬퍼 메서드 -------------------- + /** 매핑 삭제 시 연관관계 정리 */ + public void remove() { + this.post.removePostCategoryMapping(this); + this.category.removePostCategoryMapping(this); + } } diff --git a/src/main/java/com/back/domain/board/post/repository/PostCategoryMappingRepository.java b/src/main/java/com/back/domain/board/post/repository/PostCategoryMappingRepository.java new file mode 100644 index 00000000..1937097d --- /dev/null +++ b/src/main/java/com/back/domain/board/post/repository/PostCategoryMappingRepository.java @@ -0,0 +1,9 @@ +package com.back.domain.board.post.repository; + +import com.back.domain.board.post.entity.PostCategoryMapping; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostCategoryMappingRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/domain/board/post/repository/PostCategoryRepository.java b/src/main/java/com/back/domain/board/post/repository/PostCategoryRepository.java index 0e9cae7b..b1b4be0f 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostCategoryRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostCategoryRepository.java @@ -2,6 +2,8 @@ import com.back.domain.board.post.entity.PostCategory; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -9,5 +11,7 @@ @Repository public interface PostCategoryRepository extends JpaRepository { boolean existsByName(String name); - List findAllByNameIn(List categoryNames); + + @Query("SELECT c.id FROM PostCategory c WHERE c.name IN :names") + List findIdsByNameIn(@Param("names") List names); } diff --git a/src/main/java/com/back/domain/board/post/service/PostCategoryMappingService.java b/src/main/java/com/back/domain/board/post/service/PostCategoryMappingService.java new file mode 100644 index 00000000..d1650230 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/service/PostCategoryMappingService.java @@ -0,0 +1,103 @@ +package com.back.domain.board.post.service; + +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostCategory; +import com.back.domain.board.post.entity.PostCategoryMapping; +import com.back.domain.board.post.repository.PostCategoryMappingRepository; +import com.back.domain.board.post.repository.PostCategoryRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class PostCategoryMappingService { + private final PostCategoryMappingRepository postCategoryMappingRepository; + private final PostCategoryRepository postCategoryRepository; + + /** + * 게시글 생성 시 카테고리 매핑 등록 + * - 카테고리 ID 유효성 검증 후 매핑 엔티티 생성 + * + * @param post 게시글 엔티티 + * @param categoryIds 카테고리 ID 리스트 + */ + public void createMappings(Post post, List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) return; + + // 카테고리 유효성 검증 + List categories = postCategoryRepository.findAllById(categoryIds); + if (categories.size() != categoryIds.size()) { + throw new CustomException(ErrorCode.CATEGORY_NOT_FOUND); + } + + // 매핑 생성 + categories.forEach(category -> { + PostCategoryMapping mapping = new PostCategoryMapping(post, category); + postCategoryMappingRepository.save(mapping); + }); + } + + /** + * 게시글의 카테고리 매핑을 갱신 + * - 기존 매핑 제거 및 신규 매핑 추가 처리 + * + * @param post 게시글 엔티티 + * @param categoryIds 카테고리 ID 리스트 + */ + public void updateMappings(Post post, List categoryIds) { + // null 또는 빈 리스트면 → 모든 매핑 제거 + if (categoryIds == null || categoryIds.isEmpty()) { + deleteMappings(post); + return; + } + + List newCategories = postCategoryRepository.findAllById(categoryIds); + if (newCategories.size() != categoryIds.size()) { + throw new CustomException(ErrorCode.CATEGORY_NOT_FOUND); + } + + List currentMappings = post.getPostCategoryMappings(); + List currentCategories = post.getCategories(); + + // 제거 대상 + List toRemove = currentMappings.stream() + .filter(mapping -> !newCategories.contains(mapping.getCategory())) + .toList(); + + // 추가 대상 + List toAdd = newCategories.stream() + .filter(category -> !currentCategories.contains(category)) + .toList(); + + // 제거 수행 + toRemove.forEach(mapping -> { + mapping.remove(); + postCategoryMappingRepository.delete(mapping); + }); + + // 추가 수행 + toAdd.forEach(category -> { + PostCategoryMapping mapping = new PostCategoryMapping(post, category); + postCategoryMappingRepository.save(mapping); + }); + } + + /** + * 게시글의 모든 카테고리 매핑을 삭제 + * + * @param post 게시글 엔티티 + */ + public void deleteMappings(Post post) { + List existingMappings = post.getPostCategoryMappings(); + existingMappings.forEach(mapping -> { + mapping.remove(); + postCategoryMappingRepository.delete(mapping); + }); + } +} 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 index 4869b376..1ec5fa65 100644 --- a/src/main/java/com/back/domain/board/post/service/PostService.java +++ b/src/main/java/com/back/domain/board/post/service/PostService.java @@ -2,14 +2,11 @@ import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.entity.Post; -import com.back.domain.board.post.entity.PostCategory; import com.back.domain.board.post.dto.PostDetailResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.board.post.dto.PostRequest; import com.back.domain.board.post.dto.PostResponse; -import com.back.domain.board.post.entity.PostCategoryMapping; import com.back.domain.board.post.repository.PostBookmarkRepository; -import com.back.domain.board.post.repository.PostCategoryRepository; import com.back.domain.board.post.repository.PostLikeRepository; import com.back.domain.board.post.repository.PostRepository; import com.back.domain.file.entity.AttachmentMapping; @@ -38,8 +35,7 @@ public class PostService { private final PostLikeRepository postLikeRepository; private final PostBookmarkRepository postBookmarkRepository; private final UserRepository userRepository; - private final PostCategoryRepository postCategoryRepository; - private final FileAttachmentRepository fileAttachmentRepository; + private final PostCategoryMappingService postCategoryMappingService; private final AttachmentMappingRepository attachmentMappingRepository; private final AttachmentMappingService attachmentMappingService; @@ -59,20 +55,12 @@ public PostResponse createPost(PostRequest request, Long userId) { Post post = new Post(user, request.title(), request.content(), request.thumbnailUrl()); postRepository.save(post); - // Category 매핑 - if (request.categoryIds() != null) { - List categories = validateAndFindCategories(request.categoryIds()); - categories.forEach(category -> new PostCategoryMapping(post, category)); - } + // Category 매핑 생성 + postCategoryMappingService.createMappings(post, request.categoryIds()); - // AttachmentMapping 매핑 - List attachments = List.of(); - if (request.imageIds() != null && !request.imageIds().isEmpty()) { - attachments = validateAndFindAttachments(request.imageIds()); - for (FileAttachment attachment : attachments) { - attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.POST, post.getId())); - } - } + // Attachment 매핑 생성 + List attachments = + attachmentMappingService.createAttachments(EntityType.POST, post.getId(), userId, request.imageIds()); return PostResponse.from(post, attachments); } @@ -149,62 +137,15 @@ public PostResponse updatePost(Long postId, PostRequest request, Long userId) { post.update(request.title(), request.content(), request.thumbnailUrl()); // Category 매핑 업데이트 - List categories = validateAndFindCategories(request.categoryIds()); - post.updateCategories(categories); + postCategoryMappingService.updateMappings(post, request.categoryIds()); - // TODO: 리팩토링 필요 // 첨부 이미지 갱신 - List attachments = updatePostAttachments(post, request.imageIds(), userId); + List attachments = + attachmentMappingService.updateAttachments(EntityType.POST, postId, userId, request.imageIds()); return PostResponse.from(post, attachments); } - /** - * 첨부 이미지 매핑 갱신 - * - 이미지 변경 없음 → 유지 - * - 변경 있음 → 기존 파일 및 매핑 삭제 후 새로 저장 - * - 새로 추가됨 → 새 매핑 생성 - * - 제거됨 → 기존 매핑 삭제 - */ - private List updatePostAttachments(Post post, List newImageIds, Long userId) { - List newIds = (newImageIds != null) ? newImageIds : List.of(); - - // 기존 매핑 조회 - List existingMappings = - attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()); - List existingIds = existingMappings.stream() - .map(m -> m.getFileAttachment().getId()) - .toList(); - - // 변경 없음 → 유지 - if (existingIds.equals(newIds)) { - return existingMappings.stream() - .map(AttachmentMapping::getFileAttachment) - .toList(); - } - - // 기존 중 newIds에 없는 첨부만 삭제 - attachmentMappingService.deleteRemovedAttachments(EntityType.POST, post.getId(), userId, newIds); - - // 새로 추가된 첨부만 매핑 생성 - List addedIds = newIds.stream() - .filter(id -> !existingIds.contains(id)) - .toList(); - - if (!addedIds.isEmpty()) { - List newAttachments = validateAndFindAttachments(addedIds); - newAttachments.forEach(attachment -> - attachmentMappingRepository.save(new AttachmentMapping(attachment, EntityType.POST, post.getId())) - ); - } - - // 최신 매핑 다시 조회 - return attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, post.getId()) - .stream() - .map(AttachmentMapping::getFileAttachment) - .toList(); - } - /** * 게시글 삭제 서비스 * @@ -232,26 +173,4 @@ public void deletePost(Long postId, Long userId) { post.remove(); postRepository.delete(post); } - - /** - * 카테고리 ID 유효성 검증 및 조회 - */ - private List validateAndFindCategories(List categoryIds) { - if (categoryIds == null || categoryIds.isEmpty()) return List.of(); - return categoryIds.stream() - .map(id -> postCategoryRepository.findById(id) - .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND))) - .toList(); - } - - /** - * 첨부 파일 ID 유효성 검증 및 조회 - */ - private List validateAndFindAttachments(List imageIds) { - if (imageIds == null || imageIds.isEmpty()) return List.of(); - return imageIds.stream() - .map(id -> fileAttachmentRepository.findById(id) - .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND))) - .toList(); - } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java b/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java index e0fa595e..debce73e 100644 --- a/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java +++ b/src/main/java/com/back/domain/file/repository/AttachmentMappingRepository.java @@ -2,13 +2,19 @@ import com.back.domain.file.entity.AttachmentMapping; import com.back.domain.file.entity.EntityType; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; public interface AttachmentMappingRepository extends JpaRepository { + @EntityGraph(attributePaths = "fileAttachment") + List findAllByFileAttachmentIdIn(List attachmentIds); + + @EntityGraph(attributePaths = "fileAttachment") List findAllByEntityTypeAndEntityId(EntityType entityType, Long entityId); + Optional findByEntityTypeAndEntityId(EntityType entityType, Long entityId); void deleteAllByEntityTypeAndEntityId(EntityType entityType, Long entityId); diff --git a/src/main/java/com/back/domain/file/service/AttachmentMappingService.java b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java index 12dbb0d4..ac89d0d9 100644 --- a/src/main/java/com/back/domain/file/service/AttachmentMappingService.java +++ b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java @@ -25,13 +25,56 @@ public class AttachmentMappingService { private final AttachmentMappingRepository attachmentMappingRepository; private final FileAttachmentRepository fileAttachmentRepository; + /** + * 특정 엔티티에 첨부파일 매핑 생성 (게시글, 프로필 등 공통 사용) + * - 첨부파일 ID 유효성 검증 + * - 업로더(userId) 일치 여부 확인 + * - 매핑(AttachmentMapping) 엔티티 생성 및 저장 + * + * @param entityType 엔티티 종류 (POST, PROFILE 등) + * @param entityId 엔티티 ID + * @param userId 파일 업로더 검증용 + * @param attachmentIds 파일 ID 리스트 (null 또는 빈 리스트면 생성 없음) + */ + @Transactional + public List createAttachments( + EntityType entityType, + Long entityId, + Long userId, + List attachmentIds + ) { + if (attachmentIds == null || attachmentIds.isEmpty()) { + return List.of(); + } + + // 파일 ID 유효성 검증 및 업로더 확인 + List attachments = fileAttachmentRepository.findAllById(attachmentIds); + if (attachments.size() != attachmentIds.size()) { + throw new CustomException(ErrorCode.FILE_NOT_FOUND); + } + + // 업로더 검증 + boolean hasInvalid = attachments.stream() + .anyMatch(attachment -> !attachment.getUser().getId().equals(userId)); + if (hasInvalid) { + throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); + } + + // 매핑 생성 + for (FileAttachment attachment : attachments) { + attachmentMappingRepository.save(new AttachmentMapping(attachment, entityType, entityId)); + } + + return attachments; + } + /** * 특정 엔티티의 첨부파일 매핑 갱신 (게시글, 프로필 등 공통 사용) * 기존 매핑 및 파일 삭제 후, 새 첨부파일 목록으로 교체 * - * @param entityType 엔티티 종류 (POST, PROFILE 등) - * @param entityId 엔티티 ID - * @param userId 파일 업로더 검증용 + * @param entityType 엔티티 종류 (POST, PROFILE 등) + * @param entityId 엔티티 ID + * @param userId 파일 업로더 검증용 * @param newAttachmentIds 새 파일 ID 리스트 (null 또는 빈 리스트면 삭제만 수행) */ @Transactional @@ -44,12 +87,12 @@ public void replaceAttachments( // 기존 매핑 및 파일 삭제 deleteAttachments(entityType, entityId, userId); - if(newAttachmentIds == null || newAttachmentIds.isEmpty()) { + if (newAttachmentIds == null || newAttachmentIds.isEmpty()) { return; } List attachments = fileAttachmentRepository.findAllById(newAttachmentIds); - if(attachments.size() != newAttachmentIds.size()) { + if (attachments.size() != newAttachmentIds.size()) { throw new CustomException(ErrorCode.FILE_NOT_FOUND); } @@ -84,6 +127,62 @@ public void replaceAttachmentByUrl( attachmentMappingRepository.save(new AttachmentMapping(attachment, entityType, entityId)); } + /** + * 특정 엔티티의 첨부파일 매핑 갱신 (차이 기반 업데이트) + * - 변경 없음 → 유지 + * - 새 첨부 추가 → 매핑 생성 + * - 기존 첨부 제거 → 매핑 및 S3 파일 삭제 + * + * @param entityType 엔티티 종류 (POST, PROFILE 등) + * @param entityId 엔티티 ID + * @param userId 파일 업로더 검증용 + * @param newAttachmentIds 새 파일 ID 리스트 (null 또는 빈 리스트면 삭제만 수행) + */ + @Transactional + public List updateAttachments( + EntityType entityType, + Long entityId, + Long userId, + List newAttachmentIds + ) { + if (newAttachmentIds == null || newAttachmentIds.isEmpty()) { + deleteAttachments(entityType, entityId, userId); + return List.of(); + } + + // 기존 매핑 조회 + List existingMappings = + attachmentMappingRepository.findAllByEntityTypeAndEntityId(entityType, entityId); + List existingIds = existingMappings.stream() + .map(m -> m.getFileAttachment().getId()) + .toList(); + + // 변경 없음 → 그대로 반환 + if (existingIds.equals(newAttachmentIds)) { + return existingMappings.stream() + .map(AttachmentMapping::getFileAttachment) + .toList(); + } + + // 제거된 매핑 삭제 + List removedIds = existingIds.stream() + .filter(id -> !newAttachmentIds.contains(id)) + .toList(); + deleteAttachmentsByIds(userId, removedIds); + + // 새로 추가된 매핑 생성 + List addedIds = newAttachmentIds.stream() + .filter(id -> !existingIds.contains(id)) + .toList(); + createAttachments(entityType, entityId, userId, addedIds); + + // 최신 매핑 반환 + return attachmentMappingRepository.findAllByEntityTypeAndEntityId(entityType, entityId) + .stream() + .map(AttachmentMapping::getFileAttachment) + .toList(); + } + /** * 특정 EntityType과 entityId에 연결된 첨부 파일을 모두 삭제 * - S3 객체 삭제 @@ -92,14 +191,14 @@ public void replaceAttachmentByUrl( @Transactional public void deleteAttachments(EntityType entityType, Long entityId, Long userId) { List mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId( - entityType, - entityId + entityType, + entityId ); - for(AttachmentMapping mapping : mappings) { + for (AttachmentMapping mapping : mappings) { FileAttachment attachment = mapping.getFileAttachment(); - if(attachment != null) { + if (attachment != null) { // S3 오브젝트 삭제 s3Delete(attachment.getStoredName()); } @@ -110,27 +209,30 @@ public void deleteAttachments(EntityType entityType, Long entityId, Long userId) } /** - * 기존 매핑 중 새 요청(newIds)에 없는 첨부만 삭제 + * 특정 첨부파일 ID 목록만 삭제 * - S3 객체 삭제 - * - 매핑 테이블 + 파일 정보 삭제 + * - 매핑 테이블에서 제거 + * + * @param userId 파일 업로더 검증용 + * @param attachmentIds 삭제할 파일 ID 리스트 */ @Transactional - public void deleteRemovedAttachments(EntityType entityType, Long entityId, Long userId, List newIds) { - List mappings = - attachmentMappingRepository.findAllByEntityTypeAndEntityId(entityType, entityId); + public void deleteAttachmentsByIds(Long userId, List attachmentIds) { + if (attachmentIds == null || attachmentIds.isEmpty()) return; + + // 삭제할 매핑만 조회 + List mappings = attachmentMappingRepository.findAllByFileAttachmentIdIn(attachmentIds); + // 유효성 및 삭제 처리 for (AttachmentMapping mapping : mappings) { FileAttachment attachment = mapping.getFileAttachment(); - if (attachment == null) continue; - - Long attachmentId = attachment.getId(); - - // 새 요청에 포함되지 않은 첨부만 삭제 - if (!newIds.contains(attachmentId)) { - s3Delete(attachment.getStoredName()); - attachmentMappingRepository.delete(mapping); + if (!attachment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); } + + s3Delete(attachment.getStoredName()); + attachmentMappingRepository.delete(mapping); } } diff --git a/src/main/java/com/back/global/initData/DevInitService.java b/src/main/java/com/back/global/initData/DevInitService.java index e588944f..d704f373 100644 --- a/src/main/java/com/back/global/initData/DevInitService.java +++ b/src/main/java/com/back/global/initData/DevInitService.java @@ -3,14 +3,13 @@ import com.back.domain.board.comment.entity.Comment; import com.back.domain.board.comment.repository.CommentRepository; import com.back.domain.board.post.entity.Post; -import com.back.domain.board.post.entity.PostCategory; import com.back.domain.board.post.repository.PostCategoryRepository; import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.board.post.service.PostCategoryMappingService; import com.back.domain.user.common.entity.User; import com.back.domain.user.common.entity.UserProfile; import com.back.domain.user.common.enums.UserStatus; import com.back.domain.user.common.repository.UserRepository; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -21,10 +20,10 @@ @Service @RequiredArgsConstructor public class DevInitService { - private final EntityManager entityManager; private final UserRepository userRepository; private final PostRepository postRepository; private final CommentRepository commentRepository; + private final PostCategoryMappingService postCategoryMappingService; private final PostCategoryRepository postCategoryRepository; private final PasswordEncoder passwordEncoder; @@ -70,65 +69,59 @@ private void createSamplePosts(User user1, User user2, User user3) { "[백엔드] 같이 스프링 공부하실 분 구해요!", "매주 토요일 오후 2시에 온라인으로 스터디 진행합니다.\n교재는 '스프링 완전정복'을 사용할 예정입니다.", null); + postRepository.save(post1); attachCategories(post1, List.of("백엔드", "직장인", "5~10명")); Post post2 = new Post(user2, "[프론트엔드] 리액트 입문 스터디원 모집", "리액트 교재를 같이 읽고 실습해보는 스터디입니다. GitHub로 코드 리뷰도 진행합니다.", null); + postRepository.save(post2); attachCategories(post2, List.of("프론트엔드", "대학생", "2~4명")); Post post3 = new Post(user2, "[CS] 컴퓨터 구조 스터디", "운영체제, 네트워크, 컴퓨터 구조 기본 개념을 함께 정리해요.\n스터디원 5명 정도 모집합니다.", null); + postRepository.save(post3); attachCategories(post3, List.of("CS", "취준생", "5~10명")); Post post4 = new Post(user3, "[알고리즘] 백준 골드 도전 스터디", "매주 3문제씩 풀이, 코드 리뷰 및 전략 공유합니다.\n실력 향상을 목표로 합니다!", null); + postRepository.save(post4); attachCategories(post4, List.of("알고리즘", "대학생", "5~10명")); Post post5 = new Post(user1, "[영어 회화] 직장인 아침 스터디", "출근 전 30분, 영어회화 연습 스터디입니다.\n줌으로 진행하고 서로 피드백 나눠요 :)", null); + postRepository.save(post5); attachCategories(post5, List.of("영어 회화", "직장인", "2~4명")); - postRepository.saveAll(List.of(post1, post2, post3, post4, post5)); - // -------------------- 댓글 -------------------- createSampleComments(user1, user2, user3, post1, post2, post3); } private void createSampleComments(User user1, User user2, User user3, Post post1, Post post2, Post post3) { - // Post1에 댓글 Comment comment1 = Comment.createRoot(post1, user2, "저도 참여하고 싶어요!"); Comment reply1 = Comment.createChild(post1, user1, "좋아요 :) 디스코드 링크 드릴게요.", comment1); - // Post2에 댓글 Comment comment2 = Comment.createRoot(post2, user3, "스터디 모집 기간은 언제까지인가요?"); Comment reply2 = Comment.createChild(post2, user2, "이번 주 일요일까지 받을 예정이에요.", comment2); - // Post3에 댓글 Comment comment3 = Comment.createRoot(post3, user1, "CS는 항상 중요하죠 💪"); commentRepository.saveAll(List.of(comment1, reply1, comment2, reply2, comment3)); - // 게시글 commentCount 반영 post1.increaseCommentCount(); post2.increaseCommentCount(); post3.increaseCommentCount(); - - postRepository.saveAll(List.of(post1, post2, post3)); } private void attachCategories(Post post, List categoryNames) { - List categories = postCategoryRepository.findAllByNameIn(categoryNames); - if (!categories.isEmpty()) { - post.updateCategories(categories); - entityManager.flush(); - } + List categoryIds = postCategoryRepository.findIdsByNameIn(categoryNames); + postCategoryMappingService.createMappings(post, categoryIds); } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java b/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java index f049f68f..84613d42 100644 --- a/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java +++ b/src/test/java/com/back/domain/board/comment/repository/custom/CommentRepositoryImplTest.java @@ -126,8 +126,10 @@ void getCommentsByPostId_empty() { @Test @DisplayName("정렬 조건이 허용되지 않으면 기본 정렬(createdAt DESC)로 동작한다") - void getCommentsByPostId_sortFallback() { + void getCommentsByPostId_sortFallback() throws InterruptedException { // given: 허용되지 않은 정렬 필드 (likeCount만 허용됨) + Thread.sleep(5); + Comment parent4 = commentRepository.save(Comment.createRoot(post, user, "부모4")); PageRequest pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "unknownField")); // when @@ -135,7 +137,7 @@ void getCommentsByPostId_sortFallback() { // then // createdAt DESC 기본 정렬이 적용되어, 마지막에 생성된 parent3이 먼저 나와야 함 - assertThat(page.getContent().getFirst().getCommentId()).isEqualTo(parent3.getId()); + assertThat(page.getContent().getFirst().getCommentId()).isEqualTo(parent4.getId()); } // ====================== 특정 사용자의 댓글 목록 조회 테스트 ====================== diff --git a/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java b/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java index 38c82242..7744b025 100644 --- a/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java +++ b/src/test/java/com/back/domain/board/post/controller/PostControllerTest.java @@ -6,6 +6,7 @@ import com.back.domain.board.post.enums.CategoryType; import com.back.domain.board.post.repository.PostCategoryRepository; import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.board.post.service.PostCategoryMappingService; import com.back.domain.file.entity.FileAttachment; import com.back.domain.file.repository.FileAttachmentRepository; import com.back.domain.file.service.FileService; @@ -68,6 +69,8 @@ class PostControllerTest { @MockitoBean private FileService fileService; + @Autowired + private PostCategoryMappingService postCategoryMappingService; private String generateAccessToken(User user) { return testJwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name()); @@ -261,12 +264,12 @@ void getPosts_success() throws Exception { postCategoryRepository.saveAll(List.of(c1, c2)); Post post1 = new Post(user, "첫 글", "내용1", null); - post1.updateCategories(List.of(c1)); postRepository.save(post1); + postCategoryMappingService.createMappings(post1, List.of(c1.getId())); Post post2 = new Post(user, "두 번째 글", "내용2", null); - post2.updateCategories(List.of(c2)); postRepository.save(post2); + postCategoryMappingService.createMappings(post2, List.of(c2.getId())); // when mvc.perform(get("/api/posts") @@ -295,8 +298,8 @@ void getPost_success() throws Exception { postCategoryRepository.save(c1); Post post = new Post(user, "조회 테스트 글", "조회 테스트 내용", null); - post.updateCategories(List.of(c1)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(c1.getId())); // when mvc.perform(get("/api/posts/{postId}", post.getId()) @@ -338,8 +341,8 @@ void updatePost_success() throws Exception { postCategoryRepository.save(c1); Post post = new Post(user, "원래 제목", "원래 내용", null); - post.updateCategories(List.of(c1)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(c1.getId())); String accessToken = generateAccessToken(user); @@ -415,8 +418,8 @@ void updatePost_fail_noPermission() throws Exception { postCategoryRepository.save(c1); Post post = new Post(writer, "원래 제목", "원래 내용", null); - post.updateCategories(List.of(c1)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(c1.getId())); String accessToken = generateAccessToken(another); @@ -446,8 +449,8 @@ void updatePost_fail_categoryNotFound() throws Exception { postCategoryRepository.save(c1); Post post = new Post(user, "원래 제목", "원래 내용", null); - post.updateCategories(List.of(c1)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(c1.getId())); String accessToken = generateAccessToken(user); diff --git a/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java b/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java index f38bd1da..353e1974 100644 --- a/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java +++ b/src/test/java/com/back/domain/board/post/repository/custom/PostRepositoryImplTest.java @@ -4,6 +4,7 @@ import com.back.domain.board.post.entity.*; import com.back.domain.board.post.enums.CategoryType; import com.back.domain.board.post.repository.PostBookmarkRepository; +import com.back.domain.board.post.repository.PostCategoryMappingRepository; import com.back.domain.board.post.repository.PostRepository; import com.back.domain.board.post.repository.PostCategoryRepository; import com.back.domain.user.common.entity.User; @@ -46,6 +47,8 @@ class PostRepositoryImplTest { private User user; private PostCategory math, science, teen, group2; private Post post1, post2, post3; + @Autowired + private PostCategoryMappingRepository postCategoryMappingRepository; @BeforeEach void setUp() { @@ -68,9 +71,12 @@ void setUp() { postRepository.saveAll(List.of(post1, post2, post3)); // 카테고리 매핑 - post1.updateCategories(List.of(math, teen)); - post2.updateCategories(List.of(science)); - post3.updateCategories(List.of(teen, group2)); + PostCategoryMapping mapping1 = new PostCategoryMapping(post1, math); + PostCategoryMapping mapping2 = new PostCategoryMapping(post1, teen); + PostCategoryMapping mapping3 = new PostCategoryMapping(post2, science); + PostCategoryMapping mapping4 = new PostCategoryMapping(post3, teen); + PostCategoryMapping mapping5 = new PostCategoryMapping(post3, group2); + postCategoryMappingRepository.saveAll(List.of(mapping1, mapping2, mapping3, mapping4, mapping5)); } // ====================== 게시글 다건 검색 테스트 ====================== @@ -163,10 +169,10 @@ void findPostsByUserId_basic() { assertThat(page.getTotalElements()).isEqualTo(3); assertThat(page.getContent()).hasSize(3); - // 게시글 제목 확인 (생성일 역순) - assertThat(page.getContent().get(0).getTitle()).isEqualTo("10대 대상 스터디"); - assertThat(page.getContent().get(1).getTitle()).isEqualTo("과학 토론 모집"); - assertThat(page.getContent().get(2).getTitle()).isEqualTo("수학 공부 팁"); + // 게시글 제목 확인 + assertThat(page.getContent()) + .extracting("title") + .contains("수학 공부 팁", "과학 토론 모집", "10대 대상 스터디"); // 작성자 정보가 즉시 조회되었는지 확인 PostListResponse first = page.getContent().getFirst(); @@ -199,19 +205,27 @@ void findPostsByUserId_empty() { @Test @DisplayName("정렬 조건(createdAt DESC)이 올바르게 적용") - void findPostsByUserId_sorting() { + void findPostsByUserId_sorting() throws InterruptedException { // given + Thread.sleep(5); + Post early = new Post(user, "이전 글", "내용", null); + postRepository.save(early); + + Thread.sleep(5); // createdAt 차이를 확실히 줌 + Post latest = new Post(user, "최근 글", "내용", null); + postRepository.save(latest); + PageRequest pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); // when Page page = postRepository.findPostsByUserId(user.getId(), pageable); // then - assertThat(page.getContent()).hasSize(2); - assertThat(page.getContent().get(0).getTitle()).isEqualTo("10대 대상 스터디"); - assertThat(page.getContent().get(1).getTitle()).isEqualTo("과학 토론 모집"); + assertThat(page.getContent().get(0).getTitle()).isEqualTo("최근 글"); + assertThat(page.getContent().get(1).getTitle()).isEqualTo("이전 글"); } + @Test @DisplayName("카테고리가 없는 게시글도 정상 조회") void findPostsByUserId_noCategory() { @@ -252,9 +266,10 @@ void findBookmarkedPostsByUserId_basic() { assertThat(page.getTotalElements()).isEqualTo(2); assertThat(page.getContent()).hasSize(2); - // 최신순 정렬 확인 - assertThat(page.getContent().get(0).getTitle()).isEqualTo("10대 대상 스터디"); - assertThat(page.getContent().get(1).getTitle()).isEqualTo("수학 공부 팁"); + // 게시글 제목 확인 + assertThat(page.getContent()) + .extracting("title") + .contains("수학 공부 팁", "10대 대상 스터디"); // 작성자 정보 확인 PostListResponse first = page.getContent().getFirst(); @@ -283,12 +298,18 @@ void findBookmarkedPostsByUserId_empty() { @Test @DisplayName("정렬 조건(createdAt DESC)이 올바르게 적용") - void findBookmarkedPostsByUserId_sorting() { + void findBookmarkedPostsByUserId_sorting() throws InterruptedException { // given PostBookmark b1 = new PostBookmark(post1, user); + postBookmarkRepository.save(b1); + + Thread.sleep(5); // 생성 시각 차이를 확실히 줌 PostBookmark b2 = new PostBookmark(post2, user); + postBookmarkRepository.save(b2); + + Thread.sleep(5); PostBookmark b3 = new PostBookmark(post3, user); - postBookmarkRepository.saveAll(List.of(b1, b2, b3)); + postBookmarkRepository.save(b3); PageRequest pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); 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 index ab9b3aaa..2a15be5f 100644 --- a/src/test/java/com/back/domain/board/post/service/PostServiceTest.java +++ b/src/test/java/com/back/domain/board/post/service/PostServiceTest.java @@ -33,7 +33,6 @@ import org.springframework.data.domain.Sort; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -54,6 +53,9 @@ class PostServiceTest { @Autowired private UserRepository userRepository; + @Autowired + private PostCategoryMappingService postCategoryMappingService; + @Autowired private PostCategoryRepository postCategoryRepository; @@ -63,9 +65,6 @@ class PostServiceTest { @Autowired private AttachmentMappingRepository attachmentMappingRepository; - @Autowired - private AttachmentMappingService attachmentMappingService; - @MockBean private AmazonS3 amazonS3; // S3 호출 차단용 mock @@ -193,12 +192,12 @@ void getPosts_success() { postCategoryRepository.saveAll(List.of(c1, c2)); Post post1 = new Post(user, "첫 번째 글", "내용1", null); - post1.updateCategories(List.of(c1)); postRepository.save(post1); + postCategoryMappingService.createMappings(post1, List.of(c1.getId())); Post post2 = new Post(user, "두 번째 글", "내용2", null); - post2.updateCategories(List.of(c2)); postRepository.save(post2); + postCategoryMappingService.createMappings(post2, List.of(c2.getId())); Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); @@ -225,8 +224,8 @@ void getPost_success() { // 게시글 생성 Post post = new Post(user, "조회용 제목", "조회용 내용", null); - post.updateCategories(List.of(category)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(category.getId())); // 첨부 이미지 추가 MockMultipartFile file1 = new MockMultipartFile("file", "img1.png", "image/png", "dummy".getBytes()); @@ -297,8 +296,8 @@ void updatePost_success_withNewImages() { // 게시글 생성 + 기존 카테고리 세팅 Post post = new Post(user, "원래 제목", "원래 내용", null); - post.updateCategories(List.of(oldCategory)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(oldCategory.getId())); // 기존 이미지 매핑(다형 매핑 방식으로 직접 저장) attachmentMappingRepository.save(new AttachmentMapping(imgOld, EntityType.POST, post.getId())); @@ -363,8 +362,8 @@ void updatePost_fail_noPermission() { postCategoryRepository.save(category); Post post = new Post(writer, "원래 제목", "원래 내용", null); - post.updateCategories(List.of(category)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(category.getId())); PostRequest request = new PostRequest("수정된 제목", "수정된 내용", null, List.of(category.getId()), null); @@ -387,8 +386,8 @@ void updatePost_fail_categoryNotFound() { postCategoryRepository.save(category); Post post = new Post(user, "원래 제목", "원래 내용", null); - post.updateCategories(List.of(category)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(category.getId())); // 실제 DB에는 없는 카테고리 ID 전달 PostRequest request = new PostRequest("수정된 제목", "수정된 내용", null, List.of(999L), null); @@ -412,8 +411,8 @@ void updatePost_fail_fileNotFound() { postCategoryRepository.save(category); Post post = new Post(user, "원래 제목", "원래 내용", null); - post.updateCategories(List.of(category)); postRepository.save(post); + postCategoryMappingService.createMappings(post, List.of(category.getId())); PostRequest request = new PostRequest( "수정된 제목", "수정된 내용", null, diff --git a/src/test/java/com/back/domain/file/service/AttachmentMappingServiceTest.java b/src/test/java/com/back/domain/file/service/AttachmentMappingServiceTest.java index 4ea94925..159a2a43 100644 --- a/src/test/java/com/back/domain/file/service/AttachmentMappingServiceTest.java +++ b/src/test/java/com/back/domain/file/service/AttachmentMappingServiceTest.java @@ -59,6 +59,36 @@ public void tearDown() { s3Mock.stop(); } + @Test + void createAttachments_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + MockMultipartFile file1 = new MockMultipartFile("file1", "a.png", "image/png", "aaa".getBytes()); + MockMultipartFile file2 = new MockMultipartFile("file2", "b.png", "image/png", "bbb".getBytes()); + + Long id1 = fileService.uploadFile(file1, user.getId()).getAttachmentId(); + Long id2 = fileService.uploadFile(file2, user.getId()).getAttachmentId(); + + // when + List attachments = attachmentMappingService.createAttachments( + EntityType.POST, + 1L, + user.getId(), + List.of(id1, id2) + ); + + // then + assertThat(attachments).hasSize(2); + List mappings = + attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, 1L); + assertThat(mappings).hasSize(2); + assertThat(mappings.get(0).getFileAttachment().getUser().getId()).isEqualTo(user.getId()); + } + @Test void deleteAttachments_success() throws Exception { // given @@ -154,4 +184,65 @@ void replaceAttachmentsUrl_success() throws Exception { assertThat(findMapping.getFileAttachment().getId()).isEqualTo(newAttachmentId); } + @Test + void updateAttachments_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 초기 파일 2개 업로드 + Long id1 = fileService.uploadFile(new MockMultipartFile("a", "a.png", "image/png", "a".getBytes()), user.getId()).getAttachmentId(); + Long id2 = fileService.uploadFile(new MockMultipartFile("b", "b.png", "image/png", "b".getBytes()), user.getId()).getAttachmentId(); + + attachmentMappingService.createAttachments(EntityType.POST, 1L, user.getId(), List.of(id1, id2)); + + // 새로운 파일 업로드 (id3 추가) + Long id3 = fileService.uploadFile(new MockMultipartFile("c", "c.png", "image/png", "c".getBytes()), user.getId()).getAttachmentId(); + + // when (id2 제거, id3 추가) + List result = attachmentMappingService.updateAttachments( + EntityType.POST, + 1L, + user.getId(), + List.of(id1, id3) + ); + + // then + assertThat(result).hasSize(2); + List ids = result.stream().map(FileAttachment::getId).toList(); + assertThat(ids).containsExactlyInAnyOrder(id1, id3); + + // 매핑 테이블도 갱신됨 + List mappings = + attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, 1L); + assertThat(mappings).hasSize(2); + } + + @Test + void deleteAttachmentsByIds_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Long id1 = fileService.uploadFile(new MockMultipartFile("a", "a.png", "image/png", "a".getBytes()), user.getId()).getAttachmentId(); + Long id2 = fileService.uploadFile(new MockMultipartFile("b", "b.png", "image/png", "b".getBytes()), user.getId()).getAttachmentId(); + + attachmentMappingService.createAttachments(EntityType.PROFILE, 100L, user.getId(), List.of(id1, id2)); + + // when (id1만 삭제) + attachmentMappingService.deleteAttachmentsByIds(user.getId(), List.of(id1)); + + // then + List remaining = + attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.PROFILE, 100L); + assertThat(remaining).hasSize(1); + assertThat(remaining.get(0).getFileAttachment().getId()).isEqualTo(id2); + + // 파일 DB에서도 id1은 제거되어야 함 + assertThat(fileAttachmentRepository.findById(id1)).isEmpty(); + } } \ No newline at end of file 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 index 7a40afac..50401a88 100644 --- a/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java +++ b/src/test/java/com/back/domain/user/account/service/AccountServiceTest.java @@ -36,7 +36,6 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -176,7 +175,7 @@ void updateUserProfile_success() { // 새 매핑이 존재하고 기존 매핑은 삭제되었는지 검증 List mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.PROFILE, user.getUserProfile().getId()); assertThat(mappings).hasSize(1); - assertThat(mappings.get(0).getFileAttachment().getPublicURL()).isEqualTo(newAttachment.getPublicURL()); + assertThat(mappings.getFirst().getFileAttachment().getPublicURL()).isEqualTo(newAttachment.getPublicURL()); // 기존 이미지가 삭제되었는지 확인 (테스트 환경에서는 DB 삭제만 검증) assertThat(fileAttachmentRepository.findByPublicURL("https://cdn.example.com/old.png")).isEmpty(); @@ -456,8 +455,9 @@ void getMyPosts_success() { // then assertThat(response.items()).hasSize(2); - assertThat(response.items().get(0).getTitle()).isEqualTo("제목2"); // 최신순 정렬 - assertThat(response.items().get(1).getTitle()).isEqualTo("제목1"); + assertThat(response.items()) + .extracting("title") + .contains("제목1", "제목2"); } @Test @@ -533,8 +533,9 @@ void getMyComments_success() { // then assertThat(response.items()).hasSize(2); - assertThat(response.items().get(0).content()).isEqualTo("두 번째 댓글"); // 최신순 정렬 - assertThat(response.items().get(1).content()).isEqualTo("첫 번째 댓글"); + assertThat(response.items()) + .extracting("content") + .contains("첫 번째 댓글", "두 번째 댓글"); } @Test @@ -609,8 +610,9 @@ void getMyBookmarks_success() { // then assertThat(response.items()).hasSize(2); - assertThat(response.items().get(0).getTitle()).isEqualTo("테스트 코드 작성 가이드"); // 최신순 - assertThat(response.items().get(1).getTitle()).isEqualTo("JPA 영속성 전이 완벽 정리"); + assertThat(response.items()) + .extracting("title") + .contains("JPA 영속성 전이 완벽 정리", "테스트 코드 작성 가이드"); } @Test From b0977a98372e7ae6727496d9478bdf09fc4cdcb1 Mon Sep 17 00:00:00 2001 From: namgigun <84756980+namgigun@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:24:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Infra:=20=EA=B8=B0=EC=A1=B4=20AWS=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=E2=86=92=20=EC=8B=A0=EA=B7=9C=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Infra: RDS 설정 변경 - db 관련정보 하드 코딩 -> 변수화 * Infra: S3 인스턴스 설정 변경 S3 버킷명 변경 - team5-s3-1 -> catfe-s3-1 --- infra/terraform/main.tf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 9f94faa4..11fb4d0a 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -455,9 +455,9 @@ resource "aws_db_instance" "mysql" { allocated_storage = 20 storage_type = "gp2" - db_name = "catfe" - username = "catfe_user" - password = "catfe_pass" + db_name = "${var.db_name}" + username = "${var.db_username}" + password = "${var.db_password}" db_subnet_group_name = aws_db_subnet_group.db_subnet_group.name vpc_security_group_ids = [aws_security_group.rds_sg_1.id] @@ -522,7 +522,7 @@ POLICY # S3 인스턴스 생성 resource "aws_s3_bucket" "s3_1" { - bucket = "team5-s3-1" + bucket = "catfe-s3-1" tags = { Key = "TEAM" Value = "devcos-team05"