From 5520bcc0f5d25b838ae6300aee81d06dcfee9d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0?= Date: Tue, 4 Aug 2026 15:46:56 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]#111=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EB=8C=93=EA=B8=80=20isMine=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 댓글 목록 조회 응답에 isMine 추가 (GET /api/community/{communityId}/comments) - 게시글 수정 API 추가 (POST /api/community/{communityId}, multipart) - deleteImageIds 로 기존 이미지 부분 삭제, images 로 새 이미지 추가 - 톰캣이 multipart 본문을 POST 에서만 파싱해 PUT/PATCH 대신 POST 사용 - 첨부 이미지만 바뀐 경우에도 updatedAt 갱신 - 상세 조회 응답에 images(imageId + imageUrl) 추가, 기존 imageUrls 는 유지 Co-Authored-By: Claude Opus 5 --- .../controller/CommunityController.java | 19 ++- .../converter/CommunityConverter.java | 32 +++- .../dto/req/CommunityUpdateRequestDTO.java | 18 +++ .../dto/res/CommunityCommentResponseDTO.java | 6 +- .../dto/res/CommunityDetailResponseDTO.java | 2 + .../dto/res/CommunityImageResponseDTO.java | 8 + .../dto/res/CommunityUpdateResponseDTO.java | 14 ++ .../domain/community/entity/Community.java | 13 ++ .../exception/code/CommunityErrorCode.java | 6 +- .../exception/code/CommunitySuccessCode.java | 4 + .../community/service/CommunityService.java | 140 +++++++++++++++--- 11 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/redo/domain/community/dto/req/CommunityUpdateRequestDTO.java create mode 100644 src/main/java/com/redo/domain/community/dto/res/CommunityImageResponseDTO.java create mode 100644 src/main/java/com/redo/domain/community/dto/res/CommunityUpdateResponseDTO.java diff --git a/src/main/java/com/redo/domain/community/controller/CommunityController.java b/src/main/java/com/redo/domain/community/controller/CommunityController.java index 6f31837..342bc86 100644 --- a/src/main/java/com/redo/domain/community/controller/CommunityController.java +++ b/src/main/java/com/redo/domain/community/controller/CommunityController.java @@ -3,6 +3,7 @@ import com.redo.domain.community.converter.CommunityConverter; import com.redo.domain.community.dto.req.CommunityCommentCreateRequestDTO; import com.redo.domain.community.dto.req.CommunityCreateRequestDTO; +import com.redo.domain.community.dto.req.CommunityUpdateRequestDTO; import com.redo.domain.community.dto.res.CommunityCommentCreateResponseDTO; import com.redo.domain.community.dto.res.CommunityCommentDeleteResponseDTO; import com.redo.domain.community.dto.res.CommunityCommentListResponseDTO; @@ -11,6 +12,7 @@ import com.redo.domain.community.dto.res.CommunityDetailResponseDTO; import com.redo.domain.community.dto.res.CommunityLikeResponseDTO; import com.redo.domain.community.dto.res.CommunityPageResponseDTO; +import com.redo.domain.community.dto.res.CommunityUpdateResponseDTO; import com.redo.domain.community.exception.CommunityException; import com.redo.domain.community.exception.code.CommunityErrorCode; import com.redo.domain.community.exception.code.CommunitySuccessCode; @@ -99,9 +101,24 @@ public ApiResponse createCommunityPost( ); } + // 커뮤니티 게시글 수정 API + // 톰캣이 multipart 본문을 파싱하는 메서드가 기본적으로 POST 뿐이라(PUT/PATCH 는 텍스트 필드가 누락된다) POST 로 제공한다. + @PostMapping(value = "/{communityId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse updateCommunityPost( + @AuthenticationPrincipal Long userId, + @PathVariable Long communityId, + @Valid @ModelAttribute CommunityUpdateRequestDTO request + ) { + return ApiResponse.onSuccess( + CommunitySuccessCode.UPDATE_COMMUNITY_POST_SUCCESS, + communityService.updateCommunityPost(userId, communityId, request) + ); + } + // 커뮤니티 게시글 댓글 목록 조회 API @GetMapping("/{communityId}/comments") public ApiResponse getCommunityComments( + @AuthenticationPrincipal Long userId, @PathVariable Long communityId, @RequestParam(required = false) Long cursor, @RequestParam(required = false) Integer length @@ -112,7 +129,7 @@ public ApiResponse getCommunityComments( return ApiResponse.onSuccess( CommunitySuccessCode.GET_COMMUNITY_COMMENTS_SUCCESS, - communityService.getCommunityComments(communityId, cursor, length) + communityService.getCommunityComments(userId, communityId, cursor, length) ); } diff --git a/src/main/java/com/redo/domain/community/converter/CommunityConverter.java b/src/main/java/com/redo/domain/community/converter/CommunityConverter.java index 61170a0..4a8cdeb 100644 --- a/src/main/java/com/redo/domain/community/converter/CommunityConverter.java +++ b/src/main/java/com/redo/domain/community/converter/CommunityConverter.java @@ -7,9 +7,11 @@ import com.redo.domain.community.dto.res.CommunityCreateResponseDTO; import com.redo.domain.community.dto.res.CommunityDeleteResponseDTO; import com.redo.domain.community.dto.res.CommunityDetailResponseDTO; +import com.redo.domain.community.dto.res.CommunityImageResponseDTO; import com.redo.domain.community.dto.res.CommunityLikeResponseDTO; import com.redo.domain.community.dto.res.CommunityPageResponseDTO; import com.redo.domain.community.dto.res.CommunityResponseDTO; +import com.redo.domain.community.dto.res.CommunityUpdateResponseDTO; import com.redo.domain.community.entity.Community; import com.redo.domain.community.entity.CommunityComment; import com.redo.domain.community.entity.CommunityImg; @@ -72,7 +74,7 @@ public static CommunityDetailResponseDTO toCommunityDetailResponse( String writer, String profileImageUrl, String characterCode, - List imageUrls, + List images, long numComments, boolean isLiked, boolean isMine @@ -85,7 +87,8 @@ public static CommunityDetailResponseDTO toCommunityDetailResponse( characterCode, community.getContent(), community.getCreatedAt(), - imageUrls, + images.stream().map(CommunityImageResponseDTO::imageUrl).toList(), + images, String.valueOf(community.getCategory().getCode()), numComments, community.getLikeCount() == null ? 0 : community.getLikeCount(), @@ -137,6 +140,25 @@ public static CommunityCreateResponseDTO toCommunityCreateResponse( ); } + public static CommunityImageResponseDTO toCommunityImageResponse(CommunityImg image, String imageUrl) { + return new CommunityImageResponseDTO(image.getId(), imageUrl); + } + + // 수정 응답에는 수정 직후 화면을 바로 갱신할 수 있도록 남아 있는 이미지 전체(id + Presigned URL)를 담는다. + public static CommunityUpdateResponseDTO toCommunityUpdateResponse( + Community community, + List images + ) { + return new CommunityUpdateResponseDTO( + community.getId(), + community.getTitle(), + community.getContent(), + String.valueOf(community.getCategory().getCode()), + images, + community.getUpdatedAt() + ); + } + public static CommunityDeleteResponseDTO toCommunityDeleteResponse(Long communityId) { return new CommunityDeleteResponseDTO(communityId); } @@ -154,7 +176,8 @@ public static CommunityCommentResponseDTO toCommunityCommentResponse( CommunityComment comment, String writer, String profileImageUrl, - String characterCode + String characterCode, + boolean isMine ) { return new CommunityCommentResponseDTO( comment.getId(), @@ -162,7 +185,8 @@ public static CommunityCommentResponseDTO toCommunityCommentResponse( profileImageUrl, characterCode, comment.getContent(), - comment.getCreatedAt() + comment.getCreatedAt(), + isMine ); } diff --git a/src/main/java/com/redo/domain/community/dto/req/CommunityUpdateRequestDTO.java b/src/main/java/com/redo/domain/community/dto/req/CommunityUpdateRequestDTO.java new file mode 100644 index 0000000..6ca30d3 --- /dev/null +++ b/src/main/java/com/redo/domain/community/dto/req/CommunityUpdateRequestDTO.java @@ -0,0 +1,18 @@ +package com.redo.domain.community.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +// 첨부 이미지는 부분 수정한다. deleteImageIds 로 지정한 기존 이미지만 삭제하고, images 로 받은 이미지를 새로 추가한다. +// (둘 다 보내지 않으면 기존 이미지는 그대로 유지된다.) +public record CommunityUpdateRequestDTO( + @NotNull Integer category, + @NotBlank String title, + @NotBlank String content, + List deleteImageIds, + List images +) { +} diff --git a/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java b/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java index 37d6ed7..5fedfd7 100644 --- a/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java +++ b/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java @@ -1,5 +1,7 @@ package com.redo.domain.community.dto.res; +import com.fasterxml.jackson.annotation.JsonProperty; + import java.time.LocalDateTime; public record CommunityCommentResponseDTO( @@ -8,6 +10,8 @@ public record CommunityCommentResponseDTO( String profileImageUrl, String characterCode, String content, - LocalDateTime createdAt + LocalDateTime createdAt, + // ApiResponse 의 isSuccess 와 동일하게, Jackson 이 is 접두사를 떼지 않도록 이름을 고정한다. + @JsonProperty("isMine") boolean isMine ) { } diff --git a/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java b/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java index 36405bd..72a54a0 100644 --- a/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java +++ b/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java @@ -13,7 +13,9 @@ public record CommunityDetailResponseDTO( String characterCode, String content, LocalDateTime createdAt, + // imageUrls 는 기존 클라이언트 호환을 위해 유지하고, 수정 시 삭제할 이미지를 지정할 수 있도록 images(id + url)를 함께 내려준다. List imageUrls, + List images, String category, Long numComments, Integer numLikes, diff --git a/src/main/java/com/redo/domain/community/dto/res/CommunityImageResponseDTO.java b/src/main/java/com/redo/domain/community/dto/res/CommunityImageResponseDTO.java new file mode 100644 index 0000000..2202c66 --- /dev/null +++ b/src/main/java/com/redo/domain/community/dto/res/CommunityImageResponseDTO.java @@ -0,0 +1,8 @@ +package com.redo.domain.community.dto.res; + +// 수정 시 삭제할 이미지를 지정할 수 있도록 조회용 Presigned URL 과 이미지 id 를 함께 내려준다. +public record CommunityImageResponseDTO( + Long imageId, + String imageUrl +) { +} diff --git a/src/main/java/com/redo/domain/community/dto/res/CommunityUpdateResponseDTO.java b/src/main/java/com/redo/domain/community/dto/res/CommunityUpdateResponseDTO.java new file mode 100644 index 0000000..dba8375 --- /dev/null +++ b/src/main/java/com/redo/domain/community/dto/res/CommunityUpdateResponseDTO.java @@ -0,0 +1,14 @@ +package com.redo.domain.community.dto.res; + +import java.time.LocalDateTime; +import java.util.List; + +public record CommunityUpdateResponseDTO( + Long id, + String title, + String content, + String category, + List images, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/redo/domain/community/entity/Community.java b/src/main/java/com/redo/domain/community/entity/Community.java index d2371a0..8983adf 100644 --- a/src/main/java/com/redo/domain/community/entity/Community.java +++ b/src/main/java/com/redo/domain/community/entity/Community.java @@ -57,6 +57,19 @@ public class Community extends BaseEntity { @Column(name = "like_count") private Integer likeCount; + // 게시글 수정: 작성자만 호출하며 카테고리/제목/본문을 갱신한다(첨부 이미지는 별도로 교체한다). + public void update(CommunityCategory category, String title, String content) { + this.category = category; + this.title = title; + this.content = content; + } + + // 첨부 이미지만 바뀐 경우처럼 게시글 컬럼 자체는 그대로일 때도 수정 시각을 갱신하기 위해 호출한다. + // (updatedAt 이 바뀌면서 엔티티가 변경 상태가 되어 UPDATE 가 나가고, BaseEntity 의 @PreUpdate 도 동작한다.) + public void touch() { + onUpdate(); + } + // 소프트 삭제: 실제 삭제 대신 deletedAt 을 기록해 조회에서 제외한다. public void softDelete() { this.deletedAt = LocalDateTime.now(); diff --git a/src/main/java/com/redo/domain/community/exception/code/CommunityErrorCode.java b/src/main/java/com/redo/domain/community/exception/code/CommunityErrorCode.java index 95223f8..2b65200 100644 --- a/src/main/java/com/redo/domain/community/exception/code/CommunityErrorCode.java +++ b/src/main/java/com/redo/domain/community/exception/code/CommunityErrorCode.java @@ -52,7 +52,11 @@ public enum CommunityErrorCode implements BaseErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMUNITY_404_002", - "댓글이 존재하지 않습니다."); + "댓글이 존재하지 않습니다."), + + COMMUNITY_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, + "COMMUNITY_404_003", + "게시글 이미지가 존재하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/redo/domain/community/exception/code/CommunitySuccessCode.java b/src/main/java/com/redo/domain/community/exception/code/CommunitySuccessCode.java index 52782b7..ca6caf9 100644 --- a/src/main/java/com/redo/domain/community/exception/code/CommunitySuccessCode.java +++ b/src/main/java/com/redo/domain/community/exception/code/CommunitySuccessCode.java @@ -34,6 +34,10 @@ public enum CommunitySuccessCode implements BaseSuccessCode { "COMMUNITY_200_006", "좋아요를 취소했습니다."), + UPDATE_COMMUNITY_POST_SUCCESS(HttpStatus.OK, + "COMMUNITY_200_007", + "커뮤니티 게시글 수정에 성공했습니다."), + CREATE_COMMUNITY_POST_SUCCESS(HttpStatus.CREATED, "COMMUNITY_201_001", "커뮤니티 게시글 등록에 성공했습니다."), diff --git a/src/main/java/com/redo/domain/community/service/CommunityService.java b/src/main/java/com/redo/domain/community/service/CommunityService.java index ba5975d..61676cf 100644 --- a/src/main/java/com/redo/domain/community/service/CommunityService.java +++ b/src/main/java/com/redo/domain/community/service/CommunityService.java @@ -3,14 +3,17 @@ import com.redo.domain.community.converter.CommunityConverter; import com.redo.domain.community.dto.req.CommunityCommentCreateRequestDTO; import com.redo.domain.community.dto.req.CommunityCreateRequestDTO; +import com.redo.domain.community.dto.req.CommunityUpdateRequestDTO; import com.redo.domain.community.dto.res.CommunityCommentCreateResponseDTO; import com.redo.domain.community.dto.res.CommunityCommentDeleteResponseDTO; import com.redo.domain.community.dto.res.CommunityCommentListResponseDTO; import com.redo.domain.community.dto.res.CommunityCreateResponseDTO; import com.redo.domain.community.dto.res.CommunityDeleteResponseDTO; import com.redo.domain.community.dto.res.CommunityDetailResponseDTO; +import com.redo.domain.community.dto.res.CommunityImageResponseDTO; import com.redo.domain.community.dto.res.CommunityLikeResponseDTO; import com.redo.domain.community.dto.res.CommunityResponseDTO; +import com.redo.domain.community.dto.res.CommunityUpdateResponseDTO; import com.redo.domain.community.entity.Community; import com.redo.domain.community.entity.CommunityComment; import com.redo.domain.community.entity.CommunityImg; @@ -29,8 +32,10 @@ import com.redo.domain.user.repository.UserProfileRepository; import com.redo.domain.user.repository.UserRepository; import com.redo.global.apiPayload.exception.GeneralException; +import com.redo.global.s3.exception.S3Exception; import com.redo.global.s3.service.S3Service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -42,8 +47,10 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -55,6 +62,9 @@ public class CommunityService { // 게시글 이미지가 업로드되는 S3 디렉터리 접두어 private static final String IMAGE_DIRECTORY = "community"; + // 첫 번째 첨부 이미지의 display_order + private static final int FIRST_DISPLAY_ORDER = 0; + private final CommunityRepository communityRepository; private final CommunityCommentRepository communityCommentRepository; private final CommunityImgRepository communityImgRepository; @@ -99,7 +109,7 @@ public CommunityDetailResponseDTO getCommunityPost(Long userId, Long communityId getNickname(profile), getProfileImageUrl(profile), getCharacterCode(profile), - getImageUrls(community), + getImages(community), communityCommentRepository.countByCommunityAndDeletedAtIsNull(community), isLiked(userId, community), isMine(userId, community) @@ -117,10 +127,15 @@ private boolean isMine(Long userId, Community community) { return userId != null && userId.equals(community.getUser().getId()); } + // 조회자가 해당 댓글의 작성자인지 판별하는 로직 + private boolean isMine(Long userId, CommunityComment comment) { + return userId != null && userId.equals(comment.getUser().getId()); + } + // 게시글 등록 로직 @Transactional public CommunityCreateResponseDTO createCommunityPost(Long userId, CommunityCreateRequestDTO request) { - validateCreateRequest(request); + validatePostRequest(request.category(), request.title(), request.content()); CommunityCategory category = toCategory(request.category()); User user = getUser(userId); @@ -130,13 +145,45 @@ public CommunityCreateResponseDTO createCommunityPost(Long userId, CommunityCrea // 등록 응답에는 만료되는 Presigned URL 대신 저장된 S3 객체 키를 그대로 담는다. // (등록 직후 즉시 조회 용도가 아니며, 조회 시점에 상세/목록 API가 Presigned URL을 새로 발급한다.) - List imageKeys = saveImages(community, request.images()); + List imageKeys = saveImages(community, request.images(), FIRST_DISPLAY_ORDER); return CommunityConverter.toCommunityCreateResponse(community, imageKeys, getNickname(getProfile(userId))); } + // 게시글 수정 로직(작성자만 가능. 첨부 이미지는 삭제 대상만 지우고 새 이미지를 뒤에 추가한다) + @Transactional + public CommunityUpdateResponseDTO updateCommunityPost( + Long userId, + Long communityId, + CommunityUpdateRequestDTO request + ) { + validatePostRequest(request.category(), request.title(), request.content()); + + Community community = getActiveCommunity(communityId); + if (!community.getUser().getId().equals(userId)) { + throw new CommunityException(CommunityErrorCode.NOT_POST_OWNER); + } + + community.update(toCategory(request.category()), request.title(), request.content()); + + // 제목/본문 변경 없이 첨부 이미지만 바뀌면 게시글 컬럼이 그대로라 UPDATE 가 나가지 않으므로 수정 시각을 직접 갱신한다. + if (editImages(community, request.deleteImageIds(), request.images())) { + community.touch(); + } + + // 응답에 갱신된 updatedAt(@PreUpdate 로 채워진다)을 담기 위해 변경 내용을 먼저 반영한다. + communityRepository.flush(); + + return CommunityConverter.toCommunityUpdateResponse(community, getImages(community)); + } + // 댓글 목록 조회 로직(comment ID 기준 커서 페이징, cursor/length 없으면 전체 반환) - public CommunityCommentListResponseDTO getCommunityComments(Long communityId, Long cursor, Integer length) { + public CommunityCommentListResponseDTO getCommunityComments( + Long userId, + Long communityId, + Long cursor, + Integer length + ) { Community community = getActiveCommunity(communityId); List comments = communityCommentRepository @@ -155,7 +202,8 @@ public CommunityCommentListResponseDTO getCommunityComments(Long communityId, Lo comment, getNickname(profile), getProfileImageUrl(profile), - getCharacterCode(profile) + getCharacterCode(profile), + isMine(userId, comment) ); }) .toList() @@ -257,20 +305,22 @@ public CommunityDeleteResponseDTO deleteCommunityPost(Long userId, Long communit return CommunityConverter.toCommunityDeleteResponse(community.getId()); } - private void validateCreateRequest(CommunityCreateRequestDTO request) { - if (request.title() == null || request.title().isBlank()) { + // 게시글 등록/수정 공통 필수값 검증 로직 + private void validatePostRequest(Integer category, String title, String content) { + if (title == null || title.isBlank()) { throw new CommunityException(CommunityErrorCode.TITLE_REQUIRED); } - if (request.content() == null || request.content().isBlank()) { + if (content == null || content.isBlank()) { throw new CommunityException(CommunityErrorCode.CONTENT_REQUIRED); } - if (request.category() == null) { + if (category == null) { throw new CommunityException(CommunityErrorCode.INVALID_CATEGORY); } } // 게시글 이미지 S3 업로드 및 객체 키 저장 로직. 업로드한 객체 키 목록을 반환한다. - private List saveImages(Community community, List images) { + // startOrder 부터 display_order 를 순서대로 매겨 저장한다(등록은 0, 수정은 기존 이미지 다음 순서). + private List saveImages(Community community, List images, int startOrder) { if (images == null) { return List.of(); } @@ -280,12 +330,69 @@ private List saveImages(Community community, List images) .toList(); List imageKeys = s3Service.uploadAll(uploadTargets, IMAGE_DIRECTORY + "/" + community.getId()); - for (int order = 0; order < imageKeys.size(); order++) { - communityImgRepository.save(CommunityConverter.toCommunityImg(community, imageKeys.get(order), order)); + for (int index = 0; index < imageKeys.size(); index++) { + communityImgRepository.save( + CommunityConverter.toCommunityImg(community, imageKeys.get(index), startOrder + index) + ); } return imageKeys; } + // 첨부 이미지 부분 수정 로직(deleteImageIds 로 지정한 이미지만 삭제하고, 새 이미지는 기존 이미지 뒤에 추가한다) + // 실제로 삭제되거나 추가된 이미지가 있으면 true 를 반환한다. + private boolean editImages(Community community, List deleteImageIds, List images) { + List currentImages = communityImgRepository.findByCommunityOrderByDisplayOrderAsc(community); + + List deletedImageKeys = deleteImages(currentImages, deleteImageIds); + // 남은 이미지와 순서가 겹치지 않도록 기존 display_order 최댓값 다음부터 이어서 저장한다. + List addedImageKeys = saveImages(community, images, nextDisplayOrder(currentImages)); + + // S3 객체 정리는 DB 반영 이후에 수행하고, 실패하더라도 수정 자체는 성공으로 처리한다(고아 객체는 로그로 남긴다). + try { + s3Service.deleteAll(deletedImageKeys); + } catch (S3Exception exception) { + log.error("Failed to delete removed community images from S3. communityId={}, keys={}", + community.getId(), deletedImageKeys, exception); + } + + return !deletedImageKeys.isEmpty() || !addedImageKeys.isEmpty(); + } + + // 삭제 대상 이미지 행을 지우고, 정리해야 할 S3 객체 키를 반환하는 로직 + private List deleteImages(List currentImages, List deleteImageIds) { + if (deleteImageIds == null || deleteImageIds.isEmpty()) { + return List.of(); + } + + Set targetIds = deleteImageIds.stream() + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List targets = currentImages.stream() + .filter(image -> targetIds.contains(image.getId())) + .toList(); + + // 다른 게시글의 이미지 id 나 이미 삭제된 id 로 접근하는 경우는 존재하지 않는 것으로 처리한다. + if (targets.size() != targetIds.size()) { + throw new CommunityException(CommunityErrorCode.COMMUNITY_IMAGE_NOT_FOUND); + } + + // Hibernate 는 같은 flush 안에서 insert 를 delete 보다 먼저 수행하므로, 삭제를 먼저 반영시킨다. + communityImgRepository.deleteAll(targets); + communityImgRepository.flush(); + + return targets.stream() + .map(CommunityImg::getImageKey) + .toList(); + } + + // 새로 추가할 이미지가 사용할 시작 display_order 를 계산하는 로직 + private int nextDisplayOrder(List currentImages) { + return currentImages.stream() + .mapToInt(CommunityImg::getDisplayOrder) + .max() + .orElse(-1) + 1; + } + // 목록의 게시글별 삭제되지 않은 댓글 수를 한 번의 집계 쿼리로 조회하는 로직 private Map getCommentCounts(List communities) { if (communities.isEmpty()) { @@ -327,12 +434,11 @@ private Map getRepresentativeImageKeys(List communities )); } - // 상세 조회용: 첨부 이미지 전체의 S3 객체 키를 등록 순서(display_order)대로 Presigned URL로 변환하는 로직 - private List getImageUrls(Community community) { + // 상세/수정 응답용: 첨부 이미지 전체를 등록 순서(display_order)대로 id + Presigned URL 로 변환하는 로직 + private List getImages(Community community) { return communityImgRepository.findByCommunityOrderByDisplayOrderAsc(community).stream() - .map(CommunityImg::getImageKey) - .map(this::createImageUrl) - .filter(Objects::nonNull) + .map(image -> CommunityConverter.toCommunityImageResponse(image, createImageUrl(image.getImageKey()))) + .filter(image -> Objects.nonNull(image.imageUrl())) .toList(); }