From 5d1419bcc1fe0c96c07773deeaba88cec81c9713 Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 10:49:39 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20TripPermissionService=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../trip/service/TripPermissionService.java | 66 +++++++++++++++++++ .../domain/trip/service/TripService.java | 4 -- 2 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/example/pventure/domain/trip/service/TripPermissionService.java diff --git a/src/main/java/com/example/pventure/domain/trip/service/TripPermissionService.java b/src/main/java/com/example/pventure/domain/trip/service/TripPermissionService.java new file mode 100644 index 0000000..af97b65 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/trip/service/TripPermissionService.java @@ -0,0 +1,66 @@ +package com.example.pventure.domain.trip.service; + +import com.example.pventure.domain.member.service.MemberService; +import com.example.pventure.domain.trip.entity.Trip; +import com.example.pventure.domain.trip.repository.TripRepository; +import com.example.pventure.domain.user.entity.User; +import com.example.pventure.domain.user.repository.UserRepository; +import com.example.pventure.global.exception.ApiException; +import com.example.pventure.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TripPermissionService { + + private final UserRepository userRepository; + private final TripRepository tripRepository; + private final MemberService memberService; + + public Trip getEditableTrip(Long userId, Long tripId) { + User user = loadUser(userId); + Trip trip = loadTrip(tripId); + + if (!memberService.canEdit(user, trip)) { + throw new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + } + + return trip; + } + + public Trip getViewableTrip(Long userId, Long tripId) { + User user = loadUser(userId); + Trip trip = loadTrip(tripId); + + if (!memberService.isMember(user, trip)) { + throw new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + } + + return trip; + } + + public void checkEditableTrip(User user, Trip trip) { + if (!memberService.canEdit(user, trip)) { + throw new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + } + } + + public void checkViewableTrip(User user, Trip trip) { + if (!memberService.isMember(user, trip)) { + throw new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + } + } + + private User loadUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_USER)); + } + + private Trip loadTrip(Long tripId) { + return tripRepository.findById(tripId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_TRIP)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/trip/service/TripService.java b/src/main/java/com/example/pventure/domain/trip/service/TripService.java index f244932..b3a8f22 100644 --- a/src/main/java/com/example/pventure/domain/trip/service/TripService.java +++ b/src/main/java/com/example/pventure/domain/trip/service/TripService.java @@ -21,10 +21,6 @@ public interface TripService { Long countTripsWithNoDate(Long userId); - Long countTrip(Long userId); - - Long countTripsWithNoDate(Long userId); - void deleteTrip(Long userId, Long tripId); } From ffbf3dab24535b8c8b9a912c295239344be2acf1 Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 10:57:35 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20album=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../album/controller/AlbumController.java | 89 +++++ .../domain/album/controller/README.md | 1 - .../domain/album/docs/AlbumSwaggerDocs.java | 28 ++ .../domain/album/docs/CreateAlbumDocs.java | 18 + .../domain/album/docs/DeleteAlbumDocs.java | 21 ++ .../domain/album/docs/GetAlbumDocs.java | 21 ++ .../domain/album/docs/GetAlbumsDocs.java | 21 ++ .../domain/album/docs/UpdateAlbumDocs.java | 22 ++ .../dto/query/AlbumWithPhotoCountQDto.java | 18 + .../album/dto/request/AlbumRequestDto.java | 29 ++ .../domain/album/dto/request/README.md | 1 - .../album/dto/response/AlbumResponseDto.java | 42 +++ .../domain/album/dto/response/README.md | 1 - .../pventure/domain/album/entity/Album.java | 6 +- .../album/repository/AlbumRepository.java | 6 +- .../repository/AlbumRepositoryCustom.java | 11 + .../repository/AlbumRepositoryCustomImpl.java | 58 +++ .../domain/album/repository/README.md | 1 - .../domain/album/service/AlbumService.java | 19 + .../album/service/AlbumServiceImpl.java | 99 +++++ .../pventure/domain/album/service/README.md | 1 - src/test/http/album.http | 24 ++ .../album/service/AlbumServiceTest.java | 338 ++++++++++++++++++ 23 files changed, 867 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/example/pventure/domain/album/controller/AlbumController.java delete mode 100644 src/main/java/com/example/pventure/domain/album/controller/README.md create mode 100644 src/main/java/com/example/pventure/domain/album/docs/AlbumSwaggerDocs.java create mode 100644 src/main/java/com/example/pventure/domain/album/docs/CreateAlbumDocs.java create mode 100644 src/main/java/com/example/pventure/domain/album/docs/DeleteAlbumDocs.java create mode 100644 src/main/java/com/example/pventure/domain/album/docs/GetAlbumDocs.java create mode 100644 src/main/java/com/example/pventure/domain/album/docs/GetAlbumsDocs.java create mode 100644 src/main/java/com/example/pventure/domain/album/docs/UpdateAlbumDocs.java create mode 100644 src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java create mode 100644 src/main/java/com/example/pventure/domain/album/dto/request/AlbumRequestDto.java delete mode 100644 src/main/java/com/example/pventure/domain/album/dto/request/README.md create mode 100644 src/main/java/com/example/pventure/domain/album/dto/response/AlbumResponseDto.java delete mode 100644 src/main/java/com/example/pventure/domain/album/dto/response/README.md create mode 100644 src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustom.java create mode 100644 src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java delete mode 100644 src/main/java/com/example/pventure/domain/album/repository/README.md create mode 100644 src/main/java/com/example/pventure/domain/album/service/AlbumService.java create mode 100644 src/main/java/com/example/pventure/domain/album/service/AlbumServiceImpl.java delete mode 100644 src/main/java/com/example/pventure/domain/album/service/README.md create mode 100644 src/test/http/album.http create mode 100644 src/test/java/com/example/pventure/domain/album/service/AlbumServiceTest.java diff --git a/src/main/java/com/example/pventure/domain/album/controller/AlbumController.java b/src/main/java/com/example/pventure/domain/album/controller/AlbumController.java new file mode 100644 index 0000000..7bc8937 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/controller/AlbumController.java @@ -0,0 +1,89 @@ +package com.example.pventure.domain.album.controller; + +import com.example.pventure.domain.album.docs.CreateAlbumDocs; +import com.example.pventure.domain.album.docs.DeleteAlbumDocs; +import com.example.pventure.domain.album.docs.GetAlbumDocs; +import com.example.pventure.domain.album.docs.GetAlbumsDocs; +import com.example.pventure.domain.album.docs.UpdateAlbumDocs; +import com.example.pventure.domain.album.dto.request.AlbumRequestDto; +import com.example.pventure.domain.album.dto.response.AlbumResponseDto; +import com.example.pventure.domain.album.service.AlbumService; +import com.example.pventure.global.response.CustomResponse; +import com.example.pventure.global.response.CustomResponseHelper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Album", description = "앨범 관련 API") +@RestController +@RequestMapping("/trips/{tripId}/albums") +@RequiredArgsConstructor +public class AlbumController { + + private final AlbumService albumService; + + @CreateAlbumDocs + @PostMapping + public ResponseEntity> createAlbum( + @RequestParam Long userId, + @PathVariable Long tripId, + @Valid @RequestBody AlbumRequestDto requestDto + ) { + return CustomResponseHelper.created(albumService.createAlbum(userId, tripId, requestDto)); + } + + @GetAlbumsDocs + @GetMapping + public ResponseEntity>> getAlbums( + @RequestParam Long userId, + @PathVariable Long tripId + ){ + return CustomResponseHelper.ok(albumService.getAlbums(userId, tripId)); + } + + @GetAlbumDocs + @GetMapping("/{albumId}") + public ResponseEntity> getAlbum( + @RequestParam Long userId, + @PathVariable Long tripId, + @PathVariable Long albumId + ){ + return CustomResponseHelper.ok(albumService.getAlbum(userId, tripId, albumId)); + } + + @UpdateAlbumDocs + @PutMapping("/{albumId}") + public ResponseEntity> updateAlbum( + @RequestParam Long userId, + @PathVariable Long tripId, + @PathVariable Long albumId, + @Valid @RequestBody AlbumRequestDto requestDto + ) { + return CustomResponseHelper.ok(albumService.updateAlbum(userId, tripId, albumId, requestDto)); + } + + @DeleteAlbumDocs + @DeleteMapping("/{albumId}") + public ResponseEntity deleteAlbum( + @RequestParam Long userId, + @PathVariable Long tripId, + @PathVariable Long albumId + ) { + albumService.deleteAlbum(userId, tripId, albumId); + return CustomResponseHelper.noContent(); + } +} diff --git a/src/main/java/com/example/pventure/domain/album/controller/README.md b/src/main/java/com/example/pventure/domain/album/controller/README.md deleted file mode 100644 index eaa062b..0000000 --- a/src/main/java/com/example/pventure/domain/album/controller/README.md +++ /dev/null @@ -1 +0,0 @@ -# Album 관련 API 엔드포인트 정의 \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/docs/AlbumSwaggerDocs.java b/src/main/java/com/example/pventure/domain/album/docs/AlbumSwaggerDocs.java new file mode 100644 index 0000000..d26a2f3 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/docs/AlbumSwaggerDocs.java @@ -0,0 +1,28 @@ +package com.example.pventure.domain.album.docs; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AlbumSwaggerDocs { + + // --- Create --- + public static final String CREATE_ALBUM_SUMMARY = "앨범 생성"; + public static final String CREATE_ALBUM_DESCRIPTION = "사용자 ID, 여행 ID, 앨범 정보를 입력하여 새로운 여행 앨범을 생성합니다."; + + // --- Get Albums --- + public static final String GET_ALBUMS_SUMMARY = "앨범 목록 조회"; + public static final String GET_ALBUMS_DESCRIPTION = "사용자 ID와 여행 ID로 특정 여행에 속한 모든 앨범 목록을 조회합니다."; + + // --- Get Album --- + public static final String GET_ALBUM_SUMMARY = "앨범 상세 조회"; + public static final String GET_ALBUM_DESCRIPTION = "사용자 ID, 여행 ID, 앨범 ID로 특정 앨범의 상세 정보를 조회합니다."; + + // --- Update Album --- + public static final String UPDATE_ALBUM_SUMMARY = "앨범 정보 수정"; + public static final String UPDATE_ALBUM_DESCRIPTION = "사용자 ID, 여행 ID, 앨범 ID를 통해 앨범 정보를 수정합니다."; + + // --- Delete Album --- + public static final String DELETE_ALBUM_SUMMARY = "앨범 삭제"; + public static final String DELETE_ALBUM_DESCRIPTION = "사용자 ID, 여행 ID, 앨범 ID를 통해 특정 앨범을 삭제합니다."; +} diff --git a/src/main/java/com/example/pventure/domain/album/docs/CreateAlbumDocs.java b/src/main/java/com/example/pventure/domain/album/docs/CreateAlbumDocs.java new file mode 100644 index 0000000..c21a07b --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/docs/CreateAlbumDocs.java @@ -0,0 +1,18 @@ +package com.example.pventure.domain.album.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.*; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = AlbumSwaggerDocs.CREATE_ALBUM_SUMMARY, + description = AlbumSwaggerDocs.CREATE_ALBUM_DESCRIPTION +) +@ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청") +@ApiResponse(responseCode = "403", description = "편집 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 없음") +public @interface CreateAlbumDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/docs/DeleteAlbumDocs.java b/src/main/java/com/example/pventure/domain/album/docs/DeleteAlbumDocs.java new file mode 100644 index 0000000..898962c --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/docs/DeleteAlbumDocs.java @@ -0,0 +1,21 @@ +package com.example.pventure.domain.album.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = AlbumSwaggerDocs.DELETE_ALBUM_SUMMARY, + description = AlbumSwaggerDocs.DELETE_ALBUM_DESCRIPTION +) +@ApiResponse(responseCode = "204", description = "삭제 성공") +@ApiResponse(responseCode = "403", description = "편집 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 또는 앨범 없음") +public @interface DeleteAlbumDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/docs/GetAlbumDocs.java b/src/main/java/com/example/pventure/domain/album/docs/GetAlbumDocs.java new file mode 100644 index 0000000..82aaee8 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/docs/GetAlbumDocs.java @@ -0,0 +1,21 @@ +package com.example.pventure.domain.album.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = AlbumSwaggerDocs.GET_ALBUM_SUMMARY, + description = AlbumSwaggerDocs.GET_ALBUM_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "403", description = "보기 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 또는 앨범 없음") +public @interface GetAlbumDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/docs/GetAlbumsDocs.java b/src/main/java/com/example/pventure/domain/album/docs/GetAlbumsDocs.java new file mode 100644 index 0000000..52f0a31 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/docs/GetAlbumsDocs.java @@ -0,0 +1,21 @@ +package com.example.pventure.domain.album.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = AlbumSwaggerDocs.GET_ALBUMS_SUMMARY, + description = AlbumSwaggerDocs.GET_ALBUMS_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "403", description = "보기 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 없음") +public @interface GetAlbumsDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/docs/UpdateAlbumDocs.java b/src/main/java/com/example/pventure/domain/album/docs/UpdateAlbumDocs.java new file mode 100644 index 0000000..47dc263 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/docs/UpdateAlbumDocs.java @@ -0,0 +1,22 @@ +package com.example.pventure.domain.album.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = AlbumSwaggerDocs.UPDATE_ALBUM_SUMMARY, + description = AlbumSwaggerDocs.UPDATE_ALBUM_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청") +@ApiResponse(responseCode = "403", description = "편집 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 또는 앨범 없음") +public @interface UpdateAlbumDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java b/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java new file mode 100644 index 0000000..cce5425 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java @@ -0,0 +1,18 @@ +package com.example.pventure.domain.album.dto.query; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +public class AlbumWithPhotoCountQDto { + + private final Long id; + + private final String title; + + private final Long photoCount; +} diff --git a/src/main/java/com/example/pventure/domain/album/dto/request/AlbumRequestDto.java b/src/main/java/com/example/pventure/domain/album/dto/request/AlbumRequestDto.java new file mode 100644 index 0000000..89f01ee --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/dto/request/AlbumRequestDto.java @@ -0,0 +1,29 @@ +package com.example.pventure.domain.album.dto.request; + +import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.trip.entity.Trip; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "앨범 생성/수정 요청 DTO") +public class AlbumRequestDto { + + @Schema(description = "앨범 이름", example = "1일차 앨범") + @NotBlank(message = "앨범 이름을 입력해주세요.") + private String title; + + public Album toEntity(Trip trip) { + return Album.builder() + .trip(trip) + .title(title) + .build(); + } +} diff --git a/src/main/java/com/example/pventure/domain/album/dto/request/README.md b/src/main/java/com/example/pventure/domain/album/dto/request/README.md deleted file mode 100644 index 3e1b1f9..0000000 --- a/src/main/java/com/example/pventure/domain/album/dto/request/README.md +++ /dev/null @@ -1 +0,0 @@ -# Album 관련 API 요청 DTO \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/dto/response/AlbumResponseDto.java b/src/main/java/com/example/pventure/domain/album/dto/response/AlbumResponseDto.java new file mode 100644 index 0000000..b1acaf9 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/dto/response/AlbumResponseDto.java @@ -0,0 +1,42 @@ +package com.example.pventure.domain.album.dto.response; + +import com.example.pventure.domain.album.dto.query.AlbumWithPhotoCountQDto; +import com.example.pventure.domain.album.entity.Album; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "앨범 응답 DTO") +public class AlbumResponseDto { + + @Schema(description = "앨범 ID", example = "1") + private Long id; + + @Schema(description = "앨범 제목", example = "1일차 앨범") + private String title; + + @Schema(description = "사진 개수", example = "10") + private Long photoCount; + + public static AlbumResponseDto from(Album album, Long photoCount) { + return AlbumResponseDto.builder() + .id(album.getId()) + .title(album.getTitle()) + .photoCount(photoCount) + .build(); + } + + public static AlbumResponseDto from(AlbumWithPhotoCountQDto dto) { + return AlbumResponseDto.builder() + .id(dto.getId()) + .title(dto.getTitle()) + .photoCount(dto.getPhotoCount()) + .build(); + } +} diff --git a/src/main/java/com/example/pventure/domain/album/dto/response/README.md b/src/main/java/com/example/pventure/domain/album/dto/response/README.md deleted file mode 100644 index fd947d2..0000000 --- a/src/main/java/com/example/pventure/domain/album/dto/response/README.md +++ /dev/null @@ -1 +0,0 @@ -# Album 관련 API 응답 DTO \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/entity/Album.java b/src/main/java/com/example/pventure/domain/album/entity/Album.java index ee8cd60..5cc495f 100644 --- a/src/main/java/com/example/pventure/domain/album/entity/Album.java +++ b/src/main/java/com/example/pventure/domain/album/entity/Album.java @@ -24,6 +24,8 @@ public class Album extends BaseEntity { private String title; @Builder.Default - @OneToMany(mappedBy = "album", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "album") private List photos = new ArrayList<>(); -} + + public void updateTitle(String title) { this.title = title; } +} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/repository/AlbumRepository.java b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepository.java index f8c95c1..3da36a0 100644 --- a/src/main/java/com/example/pventure/domain/album/repository/AlbumRepository.java +++ b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepository.java @@ -1,7 +1,11 @@ package com.example.pventure.domain.album.repository; import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.trip.entity.Trip; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface AlbumRepository extends JpaRepository { +public interface AlbumRepository extends JpaRepository, AlbumRepositoryCustom { + + Optional findByIdAndTrip(Long id, Trip trip); } diff --git a/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustom.java b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustom.java new file mode 100644 index 0000000..2783bdc --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.example.pventure.domain.album.repository; + +import com.example.pventure.domain.album.dto.query.AlbumWithPhotoCountQDto; +import java.util.List; + +public interface AlbumRepositoryCustom { + + List findAllByTripWithPhotoCount(Long tripId); + + AlbumWithPhotoCountQDto findByIdAndTripWithPhotoCount(Long albumId, Long tripId); +} diff --git a/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java new file mode 100644 index 0000000..1474c48 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java @@ -0,0 +1,58 @@ +package com.example.pventure.domain.album.repository; + +import com.example.pventure.domain.album.dto.query.AlbumWithPhotoCountQDto; +import com.example.pventure.domain.album.entity.QAlbum; +import com.example.pventure.domain.photo.entity.QPhoto; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class AlbumRepositoryCustomImpl implements AlbumRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByTripWithPhotoCount(Long tripId) { + + QAlbum album = QAlbum.album; + + return queryAlbumWithPhotoCount() + .where(album.trip.id.eq(tripId)) + .fetch(); + } + + @Override + public AlbumWithPhotoCountQDto findByIdAndTripWithPhotoCount(Long albumId, Long tripId) { + + QAlbum album = QAlbum.album; + + return queryAlbumWithPhotoCount() + .where( + album.id.eq(albumId), + album.trip.id.eq(tripId) + ) + .fetchOne(); + } + + private JPAQuery queryAlbumWithPhotoCount() { + + QAlbum album = QAlbum.album; + QPhoto photo = QPhoto.photo; + + return queryFactory + .select(Projections.constructor( + AlbumWithPhotoCountQDto.class, + album.id, + album.title, + photo.count() + )) + .from(album) + .leftJoin(album.photos, photo) + .groupBy(album.id, album.title); + } +} diff --git a/src/main/java/com/example/pventure/domain/album/repository/README.md b/src/main/java/com/example/pventure/domain/album/repository/README.md deleted file mode 100644 index 8dae5f8..0000000 --- a/src/main/java/com/example/pventure/domain/album/repository/README.md +++ /dev/null @@ -1 +0,0 @@ -# Album 관련 DB 접근 \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/album/service/AlbumService.java b/src/main/java/com/example/pventure/domain/album/service/AlbumService.java new file mode 100644 index 0000000..792ce71 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/service/AlbumService.java @@ -0,0 +1,19 @@ +package com.example.pventure.domain.album.service; + +import com.example.pventure.domain.album.dto.request.AlbumRequestDto; +import com.example.pventure.domain.album.dto.response.AlbumResponseDto; +import java.util.List; + +public interface AlbumService { + + AlbumResponseDto createAlbum(Long userId, Long tripId, AlbumRequestDto requestDto); + + List getAlbums(Long userId, Long tripId); + + AlbumResponseDto getAlbum(Long userId, Long tripId, Long albumId); + + AlbumResponseDto updateAlbum(Long userId, Long tripId, Long albumId, AlbumRequestDto requestDto); + + void deleteAlbum(Long userId, Long tripId, Long albumId); + +} diff --git a/src/main/java/com/example/pventure/domain/album/service/AlbumServiceImpl.java b/src/main/java/com/example/pventure/domain/album/service/AlbumServiceImpl.java new file mode 100644 index 0000000..232a262 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/service/AlbumServiceImpl.java @@ -0,0 +1,99 @@ +package com.example.pventure.domain.album.service; + +import com.example.pventure.domain.album.dto.query.AlbumWithPhotoCountQDto; +import com.example.pventure.domain.album.dto.request.AlbumRequestDto; +import com.example.pventure.domain.album.dto.response.AlbumResponseDto; +import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.album.repository.AlbumRepository; +import com.example.pventure.domain.photo.entity.Photo; +import com.example.pventure.domain.photo.repository.PhotoRepository; +import com.example.pventure.domain.photo.service.PhotoService; +import com.example.pventure.domain.trip.entity.Trip; +import com.example.pventure.domain.trip.service.TripPermissionService; +import com.example.pventure.global.exception.ApiException; +import com.example.pventure.global.exception.ErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AlbumServiceImpl implements AlbumService { + + private final AlbumRepository albumRepository; + private final TripPermissionService tripPermissionService; + private final PhotoService photoService; + private final PhotoRepository photoRepository; + + @Override + public AlbumResponseDto createAlbum(Long userId, Long tripId, AlbumRequestDto requestDto) { + + Trip trip = tripPermissionService.getEditableTrip(userId, tripId); + + Album album = requestDto.toEntity(trip); + + Album savedAlbum = albumRepository.save(album); + + return AlbumResponseDto.from(savedAlbum, 0L); + } + + @Override + @Transactional(readOnly = true) + public List getAlbums(Long userId, Long tripId) { + + Trip trip = tripPermissionService.getViewableTrip(userId, tripId); + + List albums = + albumRepository.findAllByTripWithPhotoCount(trip.getId()); + + return albums.stream() + .map(AlbumResponseDto::from) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public AlbumResponseDto getAlbum(Long userId, Long tripId, Long albumId) { + + Trip trip = tripPermissionService.getViewableTrip(userId, tripId); + + AlbumWithPhotoCountQDto album = albumRepository.findByIdAndTripWithPhotoCount( + albumId, trip.getId()); + + if (album == null) { + throw new ApiException(ErrorCode.NOT_FOUND_ALBUM); + } + + return AlbumResponseDto.from(album); + } + + @Override + public AlbumResponseDto updateAlbum(Long userId, Long tripId, Long albumId, AlbumRequestDto requestDto) { + + Trip trip = tripPermissionService.getEditableTrip(userId, tripId); + + Album album = albumRepository.findByIdAndTrip(albumId, trip) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_ALBUM)); + + album.updateTitle(requestDto.getTitle()); + + return AlbumResponseDto.from(album, photoService.countPhotos(album)); + } + + @Override + public void deleteAlbum(Long userId, Long tripId, Long albumId) { + + Trip trip = tripPermissionService.getEditableTrip(userId, tripId); + + Album album = albumRepository.findByIdAndTrip(albumId, trip) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_ALBUM)); + + List photos = photoRepository.findAllByAlbum(album); + + photos.forEach(Photo::removeAlbum); + + albumRepository.delete(album); + } +} diff --git a/src/main/java/com/example/pventure/domain/album/service/README.md b/src/main/java/com/example/pventure/domain/album/service/README.md deleted file mode 100644 index cf7be80..0000000 --- a/src/main/java/com/example/pventure/domain/album/service/README.md +++ /dev/null @@ -1 +0,0 @@ -# Album 관련 비즈니스 로직 \ No newline at end of file diff --git a/src/test/http/album.http b/src/test/http/album.http new file mode 100644 index 0000000..7d685fd --- /dev/null +++ b/src/test/http/album.http @@ -0,0 +1,24 @@ +### 앨범 생성 (Create Album) +POST http://localhost:8080/api/trips/1/albums?userId=1 +Content-Type: application/json + +{ + "title": "테스트" +} + +### 앨범 목록 조회 (Get Albums) +GET http://localhost:8080/api/trips/1/albums?userId=1 + +### 앨범 상세 조회 (Get Album) +GET http://localhost:8080/api/trips/1/albums/1?userId=1 + +### 앨범 수정 (Update Album) +PUT http://localhost:8080/api/trips/1/albums/1?userId=1 +Content-Type: application/json + +{ + "title": "테스트2" +} + +### 앨범 삭제 (Delete Album) +DELETE http://localhost:8080/api/trips/1/albums/1?userId=1 \ No newline at end of file diff --git a/src/test/java/com/example/pventure/domain/album/service/AlbumServiceTest.java b/src/test/java/com/example/pventure/domain/album/service/AlbumServiceTest.java new file mode 100644 index 0000000..c8ba062 --- /dev/null +++ b/src/test/java/com/example/pventure/domain/album/service/AlbumServiceTest.java @@ -0,0 +1,338 @@ +package com.example.pventure.domain.album.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.pventure.domain.album.dto.query.AlbumWithPhotoCountQDto; +import com.example.pventure.domain.album.dto.request.AlbumRequestDto; +import com.example.pventure.domain.album.dto.response.AlbumResponseDto; +import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.album.repository.AlbumRepository; +import com.example.pventure.domain.photo.entity.Photo; +import com.example.pventure.domain.photo.repository.PhotoRepository; +import com.example.pventure.domain.photo.service.PhotoService; +import com.example.pventure.domain.trip.entity.Trip; +import com.example.pventure.domain.trip.enums.TripStatus; +import com.example.pventure.domain.trip.service.TripPermissionService; +import com.example.pventure.global.exception.ApiException; +import com.example.pventure.global.exception.ErrorCode; +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AlbumServiceTest { + + @InjectMocks + private AlbumServiceImpl albumService; + + @Mock private TripPermissionService tripPermissionService; + @Mock private AlbumRepository albumRepository; + @Mock private PhotoService photoService; + @Mock private PhotoRepository photoRepository; + + private Trip trip; + private Album album; + private AlbumRequestDto requestDto; + + @BeforeEach + void setUp() throws Exception { + trip = Trip.builder() + .title("제주 여행") + .status(TripStatus.COMPLETED) + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(3)) + .build(); + setId(trip, 10L); + + album = Album.builder() + .title("1일차 앨범") + .trip(trip) + .build(); + setId(album, 100L); + } + + private void setId(Object entity, Long id) throws Exception { + Field idField = entity.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } + + @Test + @DisplayName("createAlbum 성공") + void createAlbum_success() { + // given + Long userId = 1L; + Long tripId = 10L; + + requestDto = new AlbumRequestDto("1일차 앨범"); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.save(any(Album.class))).thenReturn(album); + + // when + AlbumResponseDto response = albumService.createAlbum(userId, tripId, requestDto); + + // then + assertEquals("1일차 앨범", response.getTitle()); + assertEquals(0L, response.getPhotoCount()); + + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).save(any(Album.class)); + } + + @Test + @DisplayName("createAlbum 실패: 편집 권한 없음") + void createAlbum_fail_permission() { + // given + Long userId = 1L; + Long tripId = 10L; + + requestDto = new AlbumRequestDto("1일차 앨범"); + + ApiException permissionEx = new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + when(tripPermissionService.getEditableTrip(userId, tripId)).thenThrow(permissionEx); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> albumService.createAlbum(userId, tripId, requestDto)); + + assertEquals(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, never()).save(any(Album.class)); + } + + @Test + @DisplayName("getAlbums 성공: 앨범 목록 반환") + void getAlbums_success() { + // given + Long userId = 1L; + Long tripId = 10L; + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + + AlbumWithPhotoCountQDto qDto1 = new AlbumWithPhotoCountQDto(100L, "1일차 앨범", 3L); + AlbumWithPhotoCountQDto qDto2 = new AlbumWithPhotoCountQDto(101L, "2일차 앨범", 5L); + + when(albumRepository.findAllByTripWithPhotoCount(trip.getId())).thenReturn(List.of(qDto1, qDto2)); + + // when + List response = albumService.getAlbums(userId, tripId); + + // then + assertEquals(2, response.size()); + assertEquals("1일차 앨범", response.get(0).getTitle()); + assertEquals("2일차 앨범", response.get(1).getTitle()); + assertEquals(3L, response.get(0).getPhotoCount()); + assertEquals(5L, response.get(1).getPhotoCount()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, times(1)).findAllByTripWithPhotoCount(trip.getId()); + } + + @Test + @DisplayName("getAlbums 성공: 앨범이 없을 경우 빈 리스트 반환") + void getAlbums_empty_success() { + // given + Long userId = 1L; + Long tripId = 10L; + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findAllByTripWithPhotoCount(trip.getId())).thenReturn(Collections.emptyList()); + + // when + List response = albumService.getAlbums(userId, tripId); + + // then + assertNotNull(response); + assertTrue(response.isEmpty()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, times(1)).findAllByTripWithPhotoCount(trip.getId()); + } + + @Test + @DisplayName("getAlbums 실패: 조회 권한 없음") + void getAlbums_fail_permission() { + // given + Long userId = 1L; + Long tripId = 10L; + + ApiException permissionEx = new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + when(tripPermissionService.getViewableTrip(userId, tripId)).thenThrow(permissionEx); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> albumService.getAlbums(userId, tripId)); + + assertEquals(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, never()).findAllByTripWithPhotoCount(anyLong()); + } + + @Test + @DisplayName("getAlbum 성공") + void getAlbum_success() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 100L; + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + + AlbumWithPhotoCountQDto qDto = new AlbumWithPhotoCountQDto(albumId, "1일차 앨범", 3L); + + when(albumRepository.findByIdAndTripWithPhotoCount(albumId, trip.getId())).thenReturn(qDto); + + // when + AlbumResponseDto response = albumService.getAlbum(userId, tripId, albumId); + + // then + assertEquals("1일차 앨범", response.getTitle()); + assertEquals(3L, response.getPhotoCount()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTripWithPhotoCount(albumId, trip.getId()); + } + + @Test + @DisplayName("getAlbum 실패: 없는 앨범") + void getAlbum_fail_albumNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 999L; + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTripWithPhotoCount(albumId, trip.getId())).thenReturn(null); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> albumService.getAlbum(userId, tripId, albumId)); + + assertEquals(ErrorCode.NOT_FOUND_ALBUM, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTripWithPhotoCount(albumId, trip.getId()); + } + + @Test + @DisplayName("updateAlbum 성공") + void updateAlbum_success() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 100L; + + requestDto = new AlbumRequestDto("수정된 앨범"); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.of(album)); + when(photoService.countPhotos(album)).thenReturn(3L); + + // when + AlbumResponseDto response = albumService.updateAlbum(userId, tripId, albumId, requestDto); + + // then + assertEquals("수정된 앨범", response.getTitle()); + assertEquals(3L, response.getPhotoCount()); + + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(albumId, trip); + verify(photoService, times(1)).countPhotos(album); + } + + @Test + @DisplayName("updateAlbum 실패: 없는 앨범") + void updateAlbum_fail_albumNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 999L; + + requestDto = new AlbumRequestDto("수정된 앨범"); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.empty()); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> albumService.updateAlbum(userId, tripId, albumId, requestDto)); + + assertEquals(ErrorCode.NOT_FOUND_ALBUM, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(albumId, trip); + verify(photoService, never()).countPhotos(any()); + } + + @Test + @DisplayName("deleteAlbum 성공") + void deleteAlbum_success() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 100L; + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.of(album)); + + Photo p1 = mock(Photo.class); + Photo p2 = mock(Photo.class); + + when(photoRepository.findAllByAlbum(album)).thenReturn(List.of(p1, p2)); + + // when + albumService.deleteAlbum(userId, tripId, albumId); + + // then + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(albumId, trip); + verify(photoRepository, times(1)).findAllByAlbum(album); + + verify(p1, times(1)).removeAlbum(); + verify(p2, times(1)).removeAlbum(); + + verify(albumRepository, times(1)).delete(album); + } + + @Test + @DisplayName("deleteAlbum 실패: 없는 앨범") + void deleteAlbum_fail_albumNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 999L; + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.empty()); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> albumService.deleteAlbum(userId, tripId, albumId)); + + assertEquals(ErrorCode.NOT_FOUND_ALBUM, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(albumId, trip); + verify(photoRepository, never()).findAllByAlbum(any(Album.class)); + verify(albumRepository, never()).delete(any(Album.class)); + } +} \ No newline at end of file From 0799770afa5d024c9b9274b3bb32a3415715f3b2 Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 12:18:58 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20S3=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 10 ++- .../pventure/global/config/S3Config.java | 40 ++++++++++++ .../pventure/global/s3/S3KeyGenerator.java | 31 +++++++++ .../example/pventure/global/s3/S3Service.java | 65 +++++++++++++++++++ src/main/resources/application-dev.yml | 8 ++- 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/pventure/global/config/S3Config.java create mode 100644 src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java create mode 100644 src/main/java/com/example/pventure/global/s3/S3Service.java diff --git a/build.gradle b/build.gradle index 6cc754f..6e3dbdb 100644 --- a/build.gradle +++ b/build.gradle @@ -29,8 +29,8 @@ dependencies { // ============================================================== implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' +// implementation 'org.springframework.boot:spring-boot-starter-security' +// implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webflux' @@ -75,6 +75,12 @@ dependencies { // ============================================================== testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + // ============================================================== + // 🪣 S3 + // ============================================================== + implementation(platform("software.amazon.awssdk:bom:2.27.21")) + implementation("software.amazon.awssdk:s3") } sourceSets { diff --git a/src/main/java/com/example/pventure/global/config/S3Config.java b/src/main/java/com/example/pventure/global/config/S3Config.java new file mode 100644 index 0000000..b1c4105 --- /dev/null +++ b/src/main/java/com/example/pventure/global/config/S3Config.java @@ -0,0 +1,40 @@ +package com.example.pventure.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + @Value("${aws.s3.region}") + private String region; + + @Bean + AwsCredentialsProvider awsCredentialsProvider() { + return DefaultCredentialsProvider.create(); + } + + @Bean + public S3Client s3Client(AwsCredentialsProvider provider) { + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(provider) + .build(); + } + + @Bean + public S3Presigner s3Presigner(AwsCredentialsProvider provider) { + + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(provider) + .build(); + } +} diff --git a/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java b/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java new file mode 100644 index 0000000..e34961c --- /dev/null +++ b/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java @@ -0,0 +1,31 @@ +package com.example.pventure.global.s3; + +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class S3KeyGenerator { + + public String generatePhotoKey(Long tripId, String originalFilename) { + String ext = extractExtension(originalFilename); + String uuid = UUID.randomUUID().toString(); + return String.format("trips/%d/photos/%s.%s", tripId, uuid, ext); + } + + public String generateTripThumbnailKey(Long tripId, String originalFilename) { + String ext = extractExtension(originalFilename); + String uuid = UUID.randomUUID().toString(); + return String.format("trips/%d/thumbnail/%s.%s", tripId, uuid, ext); + } + + public String generateProfileImgKey(Long userId, String originalFilename) { + String ext = extractExtension(originalFilename); + String uuid = UUID.randomUUID().toString(); + return String.format("users/%d/profiles/%s.%s", userId, uuid, ext); + } + + private String extractExtension(String originalFilename) { + int index = originalFilename.lastIndexOf("."); + return originalFilename.substring(index + 1); + } +} diff --git a/src/main/java/com/example/pventure/global/s3/S3Service.java b/src/main/java/com/example/pventure/global/s3/S3Service.java new file mode 100644 index 0000000..570276c --- /dev/null +++ b/src/main/java/com/example/pventure/global/s3/S3Service.java @@ -0,0 +1,65 @@ +package com.example.pventure.global.s3; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Service +@RequiredArgsConstructor +public class S3Service { + + @Value("${aws.s3.bucket}") + private String bucket; + + @Value("${aws.s3.duration}") + private Duration duration; + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + public String createUploadUrl(String key) { + + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(duration) + .putObjectRequest(objectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url().toString(); + } + + public String createDownloadUrl(String key) { + + GetObjectRequest objectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(duration) + .getObjectRequest(objectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + public void deleteFile(String key) { + + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f387b8f..f94f3a7 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -24,4 +24,10 @@ logging: level: root: info org.hibernate.SQL: debug - org.hibernate.type.descriptor.sql.BasicBinder: trace \ No newline at end of file + org.hibernate.type.descriptor.sql.BasicBinder: trace + +aws: + s3: + bucket: ${S3_BUCKET} + region: ${S3_REGION} + duration: PT10M \ No newline at end of file From 79f6667068448dadff196532644ad749bd47b253 Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 12:21:31 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20photo=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../photo/controller/PhotoController.java | 113 +++++ .../domain/photo/controller/README.md | 1 - .../domain/photo/docs/CreatePhotosDocs.java | 22 + .../domain/photo/docs/DeletePhotosDocs.java | 21 + .../photo/docs/GenerateUploadUrlsDocs.java | 22 + .../domain/photo/docs/GetPhotoDocs.java | 21 + .../photo/docs/GetPhotosByAlbumDocs.java | 21 + .../photo/docs/GetUnassignedPhotosDocs.java | 21 + .../domain/photo/docs/MovePhotosDocs.java | 22 + .../domain/photo/docs/PhotoSwaggerDocs.java | 40 ++ .../dto/request/MovePhotoRequestDto.java | 22 + .../photo/dto/request/PhotoRequestDto.java | 49 ++ .../domain/photo/dto/request/README.md | 1 - .../dto/request/UploadUrlRequestDto.java | 28 ++ .../dto/response/PhotoDetailResponseDto.java | 53 ++ .../photo/dto/response/PhotoResponseDto.java | 32 ++ .../domain/photo/dto/response/README.md | 1 - .../dto/response/UploadUrlResponseDto.java | 30 ++ .../pventure/domain/photo/entity/Photo.java | 34 +- .../photo/repository/PhotoRepository.java | 19 + .../domain/photo/repository/README.md | 1 - .../domain/photo/service/PhotoService.java | 28 ++ .../photo/service/PhotoServiceImpl.java | 201 ++++++++ .../pventure/domain/photo/service/README.md | 1 - src/test/http/photo.http | 44 ++ .../photo/service/PhotoServiceTest.java | 466 ++++++++++++++++++ 26 files changed, 1304 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/example/pventure/domain/photo/controller/PhotoController.java delete mode 100644 src/main/java/com/example/pventure/domain/photo/controller/README.md create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/CreatePhotosDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/DeletePhotosDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/GenerateUploadUrlsDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/GetPhotoDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/GetPhotosByAlbumDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/GetUnassignedPhotosDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/MovePhotosDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/docs/PhotoSwaggerDocs.java create mode 100644 src/main/java/com/example/pventure/domain/photo/dto/request/MovePhotoRequestDto.java create mode 100644 src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java delete mode 100644 src/main/java/com/example/pventure/domain/photo/dto/request/README.md create mode 100644 src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java create mode 100644 src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java create mode 100644 src/main/java/com/example/pventure/domain/photo/dto/response/PhotoResponseDto.java delete mode 100644 src/main/java/com/example/pventure/domain/photo/dto/response/README.md create mode 100644 src/main/java/com/example/pventure/domain/photo/dto/response/UploadUrlResponseDto.java delete mode 100644 src/main/java/com/example/pventure/domain/photo/repository/README.md create mode 100644 src/main/java/com/example/pventure/domain/photo/service/PhotoService.java create mode 100644 src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java delete mode 100644 src/main/java/com/example/pventure/domain/photo/service/README.md create mode 100644 src/test/http/photo.http create mode 100644 src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java diff --git a/src/main/java/com/example/pventure/domain/photo/controller/PhotoController.java b/src/main/java/com/example/pventure/domain/photo/controller/PhotoController.java new file mode 100644 index 0000000..90e6e36 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/controller/PhotoController.java @@ -0,0 +1,113 @@ +package com.example.pventure.domain.photo.controller; + +import com.example.pventure.domain.photo.docs.CreatePhotosDocs; +import com.example.pventure.domain.photo.docs.DeletePhotosDocs; +import com.example.pventure.domain.photo.docs.GenerateUploadUrlsDocs; +import com.example.pventure.domain.photo.docs.GetPhotoDocs; +import com.example.pventure.domain.photo.docs.GetPhotosByAlbumDocs; +import com.example.pventure.domain.photo.docs.GetUnassignedPhotosDocs; +import com.example.pventure.domain.photo.docs.MovePhotosDocs; +import com.example.pventure.domain.photo.dto.request.MovePhotoRequestDto; +import com.example.pventure.domain.photo.dto.request.PhotoRequestDto; +import com.example.pventure.domain.photo.dto.request.UploadUrlRequestDto; +import com.example.pventure.domain.photo.dto.response.PhotoDetailResponseDto; +import com.example.pventure.domain.photo.dto.response.PhotoResponseDto; +import com.example.pventure.domain.photo.dto.response.UploadUrlResponseDto; +import com.example.pventure.domain.photo.service.PhotoService; +import com.example.pventure.global.response.CustomResponse; +import com.example.pventure.global.response.CustomResponseHelper; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Photo", description = "사진 관련 API") +@RestController +@RequestMapping("/trips/{tripId}") +@RequiredArgsConstructor +public class PhotoController { + + private final PhotoService photoService; + + @GenerateUploadUrlsDocs + @PostMapping("/photos/upload-urls") + public ResponseEntity>> generateUploadUrls( + @RequestParam Long userId, + @PathVariable Long tripId, + @Valid @RequestBody List requestDtos + ) { + return CustomResponseHelper.ok(photoService.generateUploadUrls(userId, tripId, requestDtos)); + } + + @CreatePhotosDocs + @PostMapping("/photos") + public ResponseEntity>> createPhotos( + @RequestParam Long userId, + @PathVariable Long tripId, + @RequestParam(required = false) Long albumId, + @Valid @RequestBody List requestDtos + ) { + return CustomResponseHelper.created(photoService.createPhotos(userId, tripId, albumId, requestDtos)); + } + + @GetPhotosByAlbumDocs + @GetMapping("/albums/{albumId}/photos") + public ResponseEntity>> getPhotosByAlbum( + @RequestParam Long userId, + @PathVariable Long tripId, + @PathVariable Long albumId + ) { + return CustomResponseHelper.ok(photoService.getPhotosByAlbum(userId, tripId, albumId)); + } + + @GetUnassignedPhotosDocs + @GetMapping("/photos/unassigned") + public ResponseEntity>> getUnassignedPhotos( + @RequestParam Long userId, + @PathVariable Long tripId + ) { + return CustomResponseHelper.ok(photoService.getUnassignedPhotos(userId, tripId)); + } + + @GetPhotoDocs + @GetMapping("/photos/{photoId}") + public ResponseEntity> getPhoto( + @RequestParam Long userId, + @PathVariable Long tripId, + @PathVariable Long photoId + ) { + return CustomResponseHelper.ok(photoService.getPhoto(userId, tripId, photoId)); + } + + @MovePhotosDocs + @PutMapping("/photos/move") + public ResponseEntity movePhotos( + @RequestParam Long userId, + @PathVariable Long tripId, + @Valid @RequestBody MovePhotoRequestDto requestDto + ) { + photoService.movePhotos(userId, tripId, requestDto.getTargetAlbumId(), requestDto.getPhotoIds()); + return CustomResponseHelper.noContent(); + } + + @DeletePhotosDocs + @DeleteMapping("/photos") + public ResponseEntity deletePhotos( + @RequestParam Long userId, + @PathVariable Long tripId, + @RequestParam List photoIds + ) { + photoService.deletePhotos(userId, tripId, photoIds); + return CustomResponseHelper.noContent(); + } +} diff --git a/src/main/java/com/example/pventure/domain/photo/controller/README.md b/src/main/java/com/example/pventure/domain/photo/controller/README.md deleted file mode 100644 index 4af3b3f..0000000 --- a/src/main/java/com/example/pventure/domain/photo/controller/README.md +++ /dev/null @@ -1 +0,0 @@ -# Photo 관련 API 엔드포인트 정의 \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/docs/CreatePhotosDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/CreatePhotosDocs.java new file mode 100644 index 0000000..b195e30 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/CreatePhotosDocs.java @@ -0,0 +1,22 @@ +package com.example.pventure.domain.photo.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = PhotoSwaggerDocs.CREATE_PHOTOS_SUMMARY, + description = PhotoSwaggerDocs.CREATE_PHOTOS_DESCRIPTION +) +@ApiResponse(responseCode = "201", description = "사진 생성 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청") +@ApiResponse(responseCode = "403", description = "편집 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 없음") +public @interface CreatePhotosDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/docs/DeletePhotosDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/DeletePhotosDocs.java new file mode 100644 index 0000000..1fa6adc --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/DeletePhotosDocs.java @@ -0,0 +1,21 @@ +package com.example.pventure.domain.photo.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = PhotoSwaggerDocs.DELETE_PHOTO_SUMMARY, + description = PhotoSwaggerDocs.DELETE_PHOTO_DESCRIPTION +) +@ApiResponse(responseCode = "204", description = "삭제 성공") +@ApiResponse(responseCode = "403", description = "편집 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 또는 사진 없음") +public @interface DeletePhotosDocs {} diff --git a/src/main/java/com/example/pventure/domain/photo/docs/GenerateUploadUrlsDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/GenerateUploadUrlsDocs.java new file mode 100644 index 0000000..332a8c5 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/GenerateUploadUrlsDocs.java @@ -0,0 +1,22 @@ +package com.example.pventure.domain.photo.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = PhotoSwaggerDocs.GENERATE_UPLOAD_URLS_SUMMARY, + description = PhotoSwaggerDocs.GENERATE_UPLOAD_URLS_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "업로드 URL 생성 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청") +@ApiResponse(responseCode = "403", description = "편집 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 없음") +public @interface GenerateUploadUrlsDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/docs/GetPhotoDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/GetPhotoDocs.java new file mode 100644 index 0000000..03d27cd --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/GetPhotoDocs.java @@ -0,0 +1,21 @@ +package com.example.pventure.domain.photo.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = PhotoSwaggerDocs.GET_PHOTO_SUMMARY, + description = PhotoSwaggerDocs.GET_PHOTO_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "403", description = "보기 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 또는 사진 없음") +public @interface GetPhotoDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/docs/GetPhotosByAlbumDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/GetPhotosByAlbumDocs.java new file mode 100644 index 0000000..6051e73 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/GetPhotosByAlbumDocs.java @@ -0,0 +1,21 @@ +package com.example.pventure.domain.photo.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = PhotoSwaggerDocs.GET_PHOTOS_BY_ALBUM_SUMMARY, + description = PhotoSwaggerDocs.GET_PHOTOS_BY_ALBUM_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "403", description = "보기 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 없음") +public @interface GetPhotosByAlbumDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/docs/GetUnassignedPhotosDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/GetUnassignedPhotosDocs.java new file mode 100644 index 0000000..2b45790 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/GetUnassignedPhotosDocs.java @@ -0,0 +1,21 @@ +package com.example.pventure.domain.photo.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = PhotoSwaggerDocs.GET_UNASSIGNED_PHOTOS_SUMMARY, + description = PhotoSwaggerDocs.GET_UNASSIGNED_PHOTOS_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "403", description = "보기 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 없음") +public @interface GetUnassignedPhotosDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/docs/MovePhotosDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/MovePhotosDocs.java new file mode 100644 index 0000000..bd9a449 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/MovePhotosDocs.java @@ -0,0 +1,22 @@ +package com.example.pventure.domain.photo.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = PhotoSwaggerDocs.MOVE_PHOTOS_SUMMARY, + description = PhotoSwaggerDocs.MOVE_PHOTOS_DESCRIPTION +) +@ApiResponse(responseCode = "200", description = "이동 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청") +@ApiResponse(responseCode = "403", description = "편집 권한 없음") +@ApiResponse(responseCode = "404", description = "사용자 또는 여행 또는 사진 없음") +public @interface MovePhotosDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/docs/PhotoSwaggerDocs.java b/src/main/java/com/example/pventure/domain/photo/docs/PhotoSwaggerDocs.java new file mode 100644 index 0000000..c1744b0 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/docs/PhotoSwaggerDocs.java @@ -0,0 +1,40 @@ +package com.example.pventure.domain.photo.docs; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PhotoSwaggerDocs { + + // --- Generate Upload Presigned Urls --- + public static final String GENERATE_UPLOAD_URLS_SUMMARY = "사진 업로드용 Presigned URL 생성"; + public static final String GENERATE_UPLOAD_URLS_DESCRIPTION = "사진을 업로드하기 위한 S3 Presigned-URL 목록을 생성합니다."; + + // --- Create --- + public static final String CREATE_PHOTOS_SUMMARY = "사진 저장"; + public static final String CREATE_PHOTOS_DESCRIPTION = + "클라이언트가 S3 업로드를 완료한 뒤, 사용자 ID, 여행 ID, 앨범 ID, 사진 정보(s3Key, 파일 정보 등)를 저장합니다."; + + // --- Get Photos By Album--- + public static final String GET_PHOTOS_BY_ALBUM_SUMMARY = "앨범별 사진 목록 조회"; + public static final String GET_PHOTOS_BY_ALBUM_DESCRIPTION = "사용자 ID, 여행 ID, 앨범 ID로 특정 앨범에 포함된 사진 목록을 조회합니다. " + + "목록 조회 시 사진 ID와 다운로드용 Presigned URL만 반환합니다."; + + // --- Get Unassigned Photos --- + public static final String GET_UNASSIGNED_PHOTOS_SUMMARY = "미분류 사진 목록 조회"; + public static final String GET_UNASSIGNED_PHOTOS_DESCRIPTION = + "사용자 ID, 여행 ID로 앨범이 지정되지 않은(Album = null) 사진 목록을 조회합니다. " + + "목록 조회 시 사진 ID와 다운로드용 Presigned URL만 반환합니다."; + + // --- Get Photo --- + public static final String GET_PHOTO_SUMMARY = "사진 상세 조회"; + public static final String GET_PHOTO_DESCRIPTION = "사용자 ID, 여행 ID, 사진 ID로 특정 사진의 상세 정보를 조회합니다."; + + // --- Move Photos --- + public static final String MOVE_PHOTOS_SUMMARY = "사진 일괄 이동"; + public static final String MOVE_PHOTOS_DESCRIPTION = "선택한 사진을 특정 앨범으로 일괄 이동합니다. "; + + // --- Delete Photos --- + public static final String DELETE_PHOTO_SUMMARY = "사진 일괄 삭제"; + public static final String DELETE_PHOTO_DESCRIPTION = "선택한 사진을 DB와 S3에서 일괄 삭제합니다."; +} diff --git a/src/main/java/com/example/pventure/domain/photo/dto/request/MovePhotoRequestDto.java b/src/main/java/com/example/pventure/domain/photo/dto/request/MovePhotoRequestDto.java new file mode 100644 index 0000000..5da9a99 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/request/MovePhotoRequestDto.java @@ -0,0 +1,22 @@ +package com.example.pventure.domain.photo.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사진 이동 요청 DTO") +public class MovePhotoRequestDto { + + @Schema(description = "이동할 사진 ID 목록", example = "[1, 2, 3]") + @NotEmpty(message = "이동할 사진 ID 목록은 필수입니다.") + private List photoIds; + + @Schema(description = "이동 대상 앨범 ID (null이면 앨범 미지정)", example = "1", nullable = true) + private Long targetAlbumId; +} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java b/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java new file mode 100644 index 0000000..5c3dba3 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java @@ -0,0 +1,49 @@ +package com.example.pventure.domain.photo.dto.request; + +import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.photo.entity.Photo; +import com.example.pventure.domain.trip.entity.Trip; +import com.example.pventure.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "S3 업로드 완료 후 사진 생성 요청 DTO") +public class PhotoRequestDto { + + @Schema(description = "S3 Key", example = "trips/1/photos/uuid_IMG_1234.JPG") + @NotBlank(message = "S3 키 값을 입력해주세요.") + private String s3Key; + + @Schema(description = "원본 파일명", example = "IMG_1234.JPG") + @NotBlank(message = "파일명을 입력해주세요.") + private String originalFileName; + + @Schema(description = "파일 유형", example = "image/jpeg") + @NotBlank(message = "파일 유형을 입력해주세요.") + private String contentType; + + @Schema(description = "파일 크기", example = "345678") + @NotNull(message = "파일 크기를 입력해주세요.") + private Long fileSize; + + public Photo toEntity(Trip trip, Album album, User user) { + return Photo.builder() + .trip(trip) + .album(album) + .uploader(user) + .s3Key(s3Key) + .originalFileName(originalFileName) + .contentType(contentType) + .fileSize(fileSize) + .build(); + } +} diff --git a/src/main/java/com/example/pventure/domain/photo/dto/request/README.md b/src/main/java/com/example/pventure/domain/photo/dto/request/README.md deleted file mode 100644 index 048ee03..0000000 --- a/src/main/java/com/example/pventure/domain/photo/dto/request/README.md +++ /dev/null @@ -1 +0,0 @@ -# Photo 관련 API 요청 DTO \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java b/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java new file mode 100644 index 0000000..2922408 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java @@ -0,0 +1,28 @@ +package com.example.pventure.domain.photo.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "S3 업로드용 Presigned URL 생성 요청 DTO") +public class UploadUrlRequestDto { + + @Schema(description = "원본 파일명", example = "IMG_1234.JPG") + @NotBlank(message = "파일명을 입력해주세요.") + private String originalFileName; + + @Schema(description = "파일 유형", example = "image/jpeg") + @NotBlank(message = "파일 유형을 입력해주세요.") + private String contentType; + + @Schema(description = "파일 크기(Byte)", example = "345678") + @NotNull(message = "파일 크기를 입력해주세요.") + private Long fileSize; + +} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java b/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java new file mode 100644 index 0000000..f985189 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java @@ -0,0 +1,53 @@ +package com.example.pventure.domain.photo.dto.response; + +import com.example.pventure.domain.photo.entity.Photo; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사진 상세 조회 응답 DTO") +public class PhotoDetailResponseDto { + + @Schema(description = "사진 ID", example = "1") + private Long id; + + @Schema( + description = "사진 조회용 Presigned URL", + example = "https://bucket.s3.ap-northeast-2.amazonaws.com/photos/...?...X-Amz-Signature=..." + ) + private String downloadUrl; + + @Schema(description = "업로더 이름", example = "홍길동") + private String uploaderName; + + @Schema(description = "원본 파일명", example = "IMG_1234.JPG") + private String originalFileName; + + @Schema(description = "파일 유형", example = "image/jpeg") + private String contentType; + + @Schema(description = "파일 크기(Byte)", example = "345678") + private Long fileSize; + + @Schema(description = "업로드 시각", example = "2025-12-25T10:00:00") + private LocalDateTime createdAt; + + public static PhotoDetailResponseDto from (Photo photo, String downloadUrl) { + return PhotoDetailResponseDto.builder() + .id(photo.getId()) + .downloadUrl(downloadUrl) + .uploaderName(photo.getUploader().getName()) + .originalFileName(photo.getOriginalFileName()) + .contentType(photo.getContentType()) + .fileSize(photo.getFileSize()) + .createdAt(photo.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoResponseDto.java b/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoResponseDto.java new file mode 100644 index 0000000..12f50ef --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoResponseDto.java @@ -0,0 +1,32 @@ +package com.example.pventure.domain.photo.dto.response; + +import com.example.pventure.domain.photo.entity.Photo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사진 목록 조회 응답 DTO") +public class PhotoResponseDto { + + @Schema(description = "사진 ID", example = "1") + private Long id; + + @Schema( + description = "사진 조회용 Presigned URL", + example = "https://bucket.s3.ap-northeast-2.amazonaws.com/photos/...?...X-Amz-Signature=..." + ) + private String downloadUrl; + + public static PhotoResponseDto from (Photo photo, String downloadUrl) { + return PhotoResponseDto.builder() + .id(photo.getId()) + .downloadUrl(downloadUrl) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/dto/response/README.md b/src/main/java/com/example/pventure/domain/photo/dto/response/README.md deleted file mode 100644 index 7a9a635..0000000 --- a/src/main/java/com/example/pventure/domain/photo/dto/response/README.md +++ /dev/null @@ -1 +0,0 @@ -# Photo 관련 API 응답 DTO \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/dto/response/UploadUrlResponseDto.java b/src/main/java/com/example/pventure/domain/photo/dto/response/UploadUrlResponseDto.java new file mode 100644 index 0000000..c348fc4 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/response/UploadUrlResponseDto.java @@ -0,0 +1,30 @@ +package com.example.pventure.domain.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사진 업로드용 Presigned URL 응답 DTO") +public class UploadUrlResponseDto { + + @Schema( + description = "업로드할 객체의 S3 키", + example = "trips/1/photos/uuid_IMG_1234.JPG" + ) + private String s3Key; + + @Schema( + description = "사진 업로드용 Presigned URL", + example = "https://bucket.s3.ap-northeast-2.amazonaws.com/photos/...?...X-Amz-Signature=..." + ) + private String uploadUrl; + + @Schema(description = "원본 파일명", example = "IMG_1234.JPG") + private String originalFileName; +} diff --git a/src/main/java/com/example/pventure/domain/photo/entity/Photo.java b/src/main/java/com/example/pventure/domain/photo/entity/Photo.java index 406bf33..4c2a06a 100644 --- a/src/main/java/com/example/pventure/domain/photo/entity/Photo.java +++ b/src/main/java/com/example/pventure/domain/photo/entity/Photo.java @@ -1,6 +1,8 @@ package com.example.pventure.domain.photo.entity; import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.trip.entity.Trip; +import com.example.pventure.domain.user.entity.User; import com.example.pventure.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -13,12 +15,34 @@ public class Photo extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "album_id", nullable = false) + @JoinColumn(name = "trip_id", nullable = false) + private Trip trip; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "album_id") private Album album; - @Column(nullable = false,columnDefinition = "TEXT") - private String url; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploader_id", nullable = false) + private User uploader; + + @Column(nullable = false) + private String originalFileName; + + @Column(nullable = false) + private String contentType; + + @Column(nullable = false) + private Long fileSize; + + @Column(nullable = false) + private String s3Key; + + public void updateAlbum(Album album) { + this.album = album; + } - @Column(length = 100) - private String caption; + public void removeAlbum() { + this.album = null; + } } diff --git a/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java b/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java index 202a0f6..0e3a3e8 100644 --- a/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java +++ b/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java @@ -1,7 +1,26 @@ package com.example.pventure.domain.photo.repository; +import com.example.pventure.domain.album.entity.Album; import com.example.pventure.domain.photo.entity.Photo; +import com.example.pventure.domain.trip.entity.Trip; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PhotoRepository extends JpaRepository { + + List findAllByAlbum(Album album); + + List findAllByTripAndAlbumIsNull(Trip trip); + + List findAllByIdIn(List id); + + List findAllByIdInAndTrip(List id, Trip trip); + + Optional findByIdAndTrip(Long id, Trip trip); + + @Query("SELECT COUNT(*) FROM Photo p WHERE p.album = :album") + Long countByAlbum(@Param("album") Album album); } diff --git a/src/main/java/com/example/pventure/domain/photo/repository/README.md b/src/main/java/com/example/pventure/domain/photo/repository/README.md deleted file mode 100644 index 5fc88d6..0000000 --- a/src/main/java/com/example/pventure/domain/photo/repository/README.md +++ /dev/null @@ -1 +0,0 @@ -# Photo 관련 DB 접근 \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/service/PhotoService.java b/src/main/java/com/example/pventure/domain/photo/service/PhotoService.java new file mode 100644 index 0000000..58941a9 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/service/PhotoService.java @@ -0,0 +1,28 @@ +package com.example.pventure.domain.photo.service; + +import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.photo.dto.request.PhotoRequestDto; +import com.example.pventure.domain.photo.dto.request.UploadUrlRequestDto; +import com.example.pventure.domain.photo.dto.response.PhotoDetailResponseDto; +import com.example.pventure.domain.photo.dto.response.PhotoResponseDto; +import com.example.pventure.domain.photo.dto.response.UploadUrlResponseDto; +import java.util.List; + +public interface PhotoService { + + List generateUploadUrls(Long userId, Long tripId, List requestDtos); + + List createPhotos(Long userId, Long tripId, Long albumId, List requestDtos); + + List getPhotosByAlbum(Long userId, Long tripId, Long albumId); + + List getUnassignedPhotos(Long userId, Long tripId); + + PhotoDetailResponseDto getPhoto(Long userId, Long albumId, Long photoId); + + void movePhotos(Long userId, Long tripId, Long targetAlbumId, List albumIds); + + void deletePhotos(Long userId, Long tripId, List photoIds); + + Long countPhotos(Album album); +} diff --git a/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java b/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java new file mode 100644 index 0000000..87386d0 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java @@ -0,0 +1,201 @@ +package com.example.pventure.domain.photo.service; + +import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.album.repository.AlbumRepository; +import com.example.pventure.domain.photo.dto.request.PhotoRequestDto; +import com.example.pventure.domain.photo.dto.request.UploadUrlRequestDto; +import com.example.pventure.domain.photo.dto.response.PhotoDetailResponseDto; +import com.example.pventure.domain.photo.dto.response.PhotoResponseDto; +import com.example.pventure.domain.photo.dto.response.UploadUrlResponseDto; +import com.example.pventure.domain.photo.entity.Photo; +import com.example.pventure.domain.photo.repository.PhotoRepository; +import com.example.pventure.domain.trip.entity.Trip; +import com.example.pventure.domain.trip.repository.TripRepository; +import com.example.pventure.domain.trip.service.TripPermissionService; +import com.example.pventure.domain.user.entity.User; +import com.example.pventure.domain.user.repository.UserRepository; +import com.example.pventure.global.exception.ApiException; +import com.example.pventure.global.exception.ErrorCode; +import com.example.pventure.global.s3.S3KeyGenerator; +import com.example.pventure.global.s3.S3Service; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class PhotoServiceImpl implements PhotoService { + + private final S3Service s3Service; + private final TripPermissionService tripPermissionService; + private final S3KeyGenerator s3KeyGenerator; + private final UserRepository userRepository; + private final TripRepository tripRepository; + private final AlbumRepository albumRepository; + private final PhotoRepository photoRepository; + + @Override + @Transactional(readOnly = true) + public List generateUploadUrls(Long userId, Long tripId, List requestDtos) { + + Trip trip = tripPermissionService.getEditableTrip(userId, tripId); + + return requestDtos.stream() + .map(requestDto -> { + String s3Key = s3KeyGenerator.generatePhotoKey(trip.getId(), requestDto.getOriginalFileName()); + String uploadUrl = s3Service.createUploadUrl(s3Key); + + return UploadUrlResponseDto.builder() + .s3Key(s3Key) + .uploadUrl(uploadUrl) + .originalFileName(requestDto.getOriginalFileName()) + .build(); + }) + .toList(); + } + + @Override + public List createPhotos(Long userId, Long tripId, Long albumId, List requestDtos) { + + User user = loadUser(userId); + Trip trip = loadTrip(tripId); + + tripPermissionService.checkEditableTrip(user, trip); + + Album album; + + if (albumId != null) { + album = loadAlbum(albumId, trip); + } else { + album = null; + } + + return requestDtos.stream() + .map(requestDto -> { + + Photo photo = requestDto.toEntity(trip, album, user); + Photo savedPhoto = photoRepository.save(photo); + + String downloadUrl = s3Service.createDownloadUrl(savedPhoto.getS3Key()); + + return PhotoResponseDto.from(savedPhoto, downloadUrl); + }) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public List getPhotosByAlbum(Long userId, Long tripId, Long albumId) { + + Trip trip = tripPermissionService.getViewableTrip(userId, tripId); + + Album album = loadAlbum(albumId, trip); + + List photos = photoRepository.findAllByAlbum(album); + + return photos.stream() + .map(photo -> { + String downloadUrl = s3Service.createDownloadUrl(photo.getS3Key()); + return PhotoResponseDto.from(photo, downloadUrl); + }) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public List getUnassignedPhotos(Long userId, Long tripId) { + + Trip trip = tripPermissionService.getViewableTrip(userId, tripId); + + List photos = photoRepository.findAllByTripAndAlbumIsNull(trip); + + return photos.stream() + .map(photo -> { + String downloadUrl = s3Service.createDownloadUrl(photo.getS3Key()); + return PhotoResponseDto.from(photo, downloadUrl); + }) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public PhotoDetailResponseDto getPhoto(Long userId, Long tripId, Long photoId) { + + Trip trip = tripPermissionService.getViewableTrip(userId, tripId); + + Photo photo = photoRepository.findByIdAndTrip(photoId, trip) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_PHOTO)); + + String downloadUrl = s3Service.createDownloadUrl(photo.getS3Key()); + + return PhotoDetailResponseDto.from(photo, downloadUrl); + } + + @Override + public void movePhotos(Long userId, Long tripId, Long targetAlbumId, List photoIds) { + + if (photoIds == null || photoIds.isEmpty()) { + return; + } + + Trip trip = tripPermissionService.getEditableTrip(userId, tripId); + + Album album; + + if (targetAlbumId != null) { + album = loadAlbum(targetAlbumId, trip); + } else { + album = null; + } + + List photos = photoRepository.findAllByIdInAndTrip(photoIds, trip); + + if (photos.size() != photoIds.size()) { + throw new ApiException(ErrorCode.NOT_FOUND_PHOTO); + } + + photos.forEach(photo -> photo.updateAlbum(album)); + } + + @Override + public void deletePhotos(Long userId, Long tripId, List photoIds) { + + if (photoIds == null || photoIds.isEmpty()) { + return; + } + + Trip trip = tripPermissionService.getEditableTrip(userId, tripId); + + List photos = photoRepository.findAllByIdInAndTrip(photoIds, trip); + + if (photos.size() != photoIds.size()) { + throw new ApiException(ErrorCode.NOT_FOUND_PHOTO); + } + + photos.forEach(photo -> s3Service.deleteFile(photo.getS3Key())); + + photoRepository.deleteAll(photos); + } + + @Override + public Long countPhotos(Album album) { + return photoRepository.countByAlbum(album); + } + + private User loadUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_USER)); + } + + private Trip loadTrip(Long tripId) { + return tripRepository.findById(tripId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_TRIP)); + } + + private Album loadAlbum(Long albumId, Trip trip) { + return albumRepository.findByIdAndTrip(albumId, trip) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_ALBUM)); + } +} diff --git a/src/main/java/com/example/pventure/domain/photo/service/README.md b/src/main/java/com/example/pventure/domain/photo/service/README.md deleted file mode 100644 index 993bc16..0000000 --- a/src/main/java/com/example/pventure/domain/photo/service/README.md +++ /dev/null @@ -1 +0,0 @@ -# Photo 관련 비즈니스 로직 \ No newline at end of file diff --git a/src/test/http/photo.http b/src/test/http/photo.http new file mode 100644 index 0000000..3f6d0f0 --- /dev/null +++ b/src/test/http/photo.http @@ -0,0 +1,44 @@ +### 업로드 URL 생성 (Generate Upload Urls) +POST http://localhost:8080/api/trips/1/photos/upload-urls?userId=1 +Content-Type: application/json + +[ + { + "originalFileName": "sampleImage1.jpeg", + "contentType": "image/jpeg", + "fileSize": 175042 + } +] + +### 사진 생성 (Create Photos) +POST http://localhost:8080/api/trips/1/photos?userId=1 +Content-Type: application/json + +[ + { + "s3Key" : "trips/1/photos/b1f7fdb1-9b2a-403b-9f4a-37cad070e306.jpeg", + "originalFileName" : "sampleImage1.jpeg", + "contentType" : "image/jpeg", + "fileSize" : 175042 + } +] + +### 앨범별 사진 조회 (Get Photos By Album) +GET http://localhost:8080/api/trips/1/albums/1/photos?userId=1 + +### 앨범 미지정 사진 조회 (Get Unassigned Photos) +GET http://localhost:8080/api/trips/1/photos/unassigned?userId=1 + +### 사진 상세 조회 (Get Photo) +GET http://localhost:8080/api/trips/1/photos/1?userId=1 + +### 특정 앨범으로 사진 이동 (Move Photos) +PATCH http://localhost:8080/api/trips/1/albums/1/photos/move?userId=1 +Content-Type: application/json + +{ + "photoIds" : [13] +} + +### 사진 삭제 (Delete Photos) +DELETE http://localhost:8080/api/trips/1?userId=1&photoIds=12 diff --git a/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java b/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java new file mode 100644 index 0000000..2306980 --- /dev/null +++ b/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java @@ -0,0 +1,466 @@ +package com.example.pventure.domain.photo.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.pventure.domain.album.entity.Album; +import com.example.pventure.domain.album.repository.AlbumRepository; +import com.example.pventure.domain.photo.dto.request.PhotoRequestDto; +import com.example.pventure.domain.photo.dto.response.PhotoDetailResponseDto; +import com.example.pventure.domain.photo.dto.response.PhotoResponseDto; +import com.example.pventure.domain.photo.entity.Photo; +import com.example.pventure.domain.photo.repository.PhotoRepository; +import com.example.pventure.domain.trip.entity.Trip; +import com.example.pventure.domain.trip.enums.TripStatus; +import com.example.pventure.domain.trip.repository.TripRepository; +import com.example.pventure.domain.trip.service.TripPermissionService; +import com.example.pventure.domain.user.entity.User; +import com.example.pventure.domain.user.enums.SocialProvider; +import com.example.pventure.domain.user.repository.UserRepository; +import com.example.pventure.global.exception.ApiException; +import com.example.pventure.global.exception.ErrorCode; +import com.example.pventure.global.s3.S3KeyGenerator; +import com.example.pventure.global.s3.S3Service; +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PhotoServiceTest { + + @InjectMocks + private PhotoServiceImpl photoService; + + @Mock private S3Service s3Service; + @Mock private TripPermissionService tripPermissionService; + @Mock private UserRepository userRepository; + @Mock private TripRepository tripRepository; + @Mock private AlbumRepository albumRepository; + @Mock private PhotoRepository photoRepository; + + private User user; + private Trip trip; + private Album album; + private Photo photo; + + @BeforeEach + void setUp() throws Exception { + user = User.builder() + .name("홍길동") + .email("hong@example.com") + .socialProvider(SocialProvider.GOOGLE) + .providerId("google-id-123") + .imageUrl("https://example.com/image.jpg") + .build(); + setId(user, 1L); + + trip = Trip.builder() + .title("제주 여행") + .status(TripStatus.COMPLETED) + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(3)) + .build(); + setId(trip, 10L); + + album = Album.builder() + .title("1일차 앨범") + .trip(trip) + .build(); + setId(album, 100L); + + photo = Photo.builder() + .trip(trip) + .album(album) + .uploader(user) + .originalFileName("IMG_01.JPG") + .contentType("image/jpeg") + .fileSize(345678L) + .s3Key("trips/10/photos/uuid_IMG_01.JPG") + .build(); + setId(photo, 1000L); + } + + private void setId(Object entity, Long id) throws Exception { + Field idField = entity.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } + + @Test + @DisplayName("createPhotos 성공: albumI가 있는 경우") + void createPhotos_success_withAlbum() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 100L; + + PhotoRequestDto requestDto = mock(PhotoRequestDto.class); + Photo photo = mock(Photo.class); + Photo savedPhoto = mock(Photo.class); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(tripRepository.findById(tripId)).thenReturn(Optional.of(trip)); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.of(album)); + + when(requestDto.toEntity(trip, album, user)).thenReturn(photo); + when(photoRepository.save(photo)).thenReturn(savedPhoto); + + when(savedPhoto.getId()).thenReturn(1000L); + when(savedPhoto.getS3Key()).thenReturn("s3/key/1000"); + when(s3Service.createDownloadUrl("s3/key/1000")).thenReturn("download-url-1000"); + + // when + List response = photoService.createPhotos(userId, tripId, albumId, List.of(requestDto)); + + // then + assertEquals(1, response.size()); + assertEquals(1000L, response.get(0).getId()); + assertEquals("download-url-1000", response.get(0).getDownloadUrl()); + + verify(tripPermissionService, times(1)).checkEditableTrip(user, trip); + verify(albumRepository, times(1)).findByIdAndTrip(albumId, trip); + verify(photoRepository, times(1)).save(photo); + verify(s3Service, times(1)).createDownloadUrl("s3/key/1000"); + } + + @Test + @DisplayName("createPhotos 성공: albumId가 null인 경우") + void createPhotos_success_withoutAlbum() { + // given + Long userId = 1L; + Long tripId = 10L; + + PhotoRequestDto requestDto = mock(PhotoRequestDto.class); + Photo photo = mock(Photo.class); + Photo savedPhoto = mock(Photo.class); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(tripRepository.findById(tripId)).thenReturn(Optional.of(trip)); + + when(requestDto.toEntity(trip, null, user)).thenReturn(photo); + when(photoRepository.save(photo)).thenReturn(savedPhoto); + + when(savedPhoto.getId()).thenReturn(1000L); + when(savedPhoto.getS3Key()).thenReturn("s3/key/1000"); + when(s3Service.createDownloadUrl("s3/key/1000")).thenReturn("download-url-1000"); + + // when + List response = photoService.createPhotos(userId, tripId, null, List.of(requestDto)); + + // then + assertEquals(1, response.size()); + assertEquals(1000L, response.get(0).getId()); + assertEquals("download-url-1000", response.get(0).getDownloadUrl()); + + verify(tripPermissionService, times(1)).checkEditableTrip(user, trip); + verify(albumRepository, never()).findByIdAndTrip(anyLong(), any(Trip.class)); + verify(photoRepository, times(1)).save(photo); + verify(s3Service, times(1)).createDownloadUrl("s3/key/1000"); + } + + @Test + @DisplayName("createPhotos 실패: 없는 앨범") + void createPhoto_fail_albumNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 999L; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(tripRepository.findById(tripId)).thenReturn(Optional.of(trip)); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.empty()); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> photoService.createPhotos(userId, tripId, albumId, List.of())); + + assertEquals(ErrorCode.NOT_FOUND_ALBUM, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).checkEditableTrip(user, trip); + verify(photoRepository, never()).save(any()); + verify(s3Service, never()).createDownloadUrl(anyString()); + } + + @Test + @DisplayName("getPhotosByAlbum 성공") + void getPhotosByAlbum_success() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 100L; + + Photo p1 = mock(Photo.class); + Photo p2 = mock(Photo.class); + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.of(album)); + when(photoRepository.findAllByAlbum(album)).thenReturn(List.of(p1, p2)); + + when(p1.getId()).thenReturn(1000L); + when(p2.getId()).thenReturn(1001L); + + when(p1.getS3Key()).thenReturn("s3/key/1"); + when(p2.getS3Key()).thenReturn("s3/key/2"); + + when(s3Service.createDownloadUrl(p1.getS3Key())).thenReturn("download-url-1"); + when(s3Service.createDownloadUrl(p2.getS3Key())).thenReturn("download-url-2"); + + // when + List response = photoService.getPhotosByAlbum(userId, tripId, albumId); + + // then + assertEquals(2, response.size()); + assertEquals(1000L, response.get(0).getId()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(albumId, trip); + verify(photoRepository, times(1)).findAllByAlbum(album); + verify(s3Service, times(1)).createDownloadUrl("s3/key/1"); + verify(s3Service, times(1)).createDownloadUrl("s3/key/2"); + } + + @Test + @DisplayName("getPhotosByAlbum 실패: 조회 권한 없음") + void getPhotosByAlbum_fail_permission() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 100L; + + ApiException permissionEx = new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + when(tripPermissionService.getViewableTrip(userId, tripId)).thenThrow(permissionEx); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> photoService.getPhotosByAlbum(userId, tripId, albumId)); + + assertEquals(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, never()).findByIdAndTrip(anyLong(), any(Trip.class)); + verify(photoRepository, never()).findAllByAlbum(any(Album.class)); + verify(s3Service, never()).createDownloadUrl(anyString()); + } + + @Test + @DisplayName("getPhotosByAlbum 실패: 없는 앨범") + void getPhotosByAlbum_fail_albumNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + Long albumId = 999L; + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(albumId, trip)).thenReturn(Optional.empty()); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> photoService.getPhotosByAlbum(userId, tripId, albumId)); + + assertEquals(ErrorCode.NOT_FOUND_ALBUM, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(albumId, trip); + verify(photoRepository, never()).findAllByAlbum(any(Album.class)); + verify(s3Service, never()).createDownloadUrl(anyString()); + } + + @Test + @DisplayName("getUnassignedPhotos 성공") + void getUnassignedPhotos_success() { + // given + Long userId = 1L; + Long tripId = 10L; + + photo.removeAlbum(); + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + when(photoRepository.findAllByTripAndAlbumIsNull(trip)).thenReturn(List.of(photo)); + when(s3Service.createDownloadUrl(photo.getS3Key())).thenReturn("download-url-1"); + + // when + List response = photoService.getUnassignedPhotos(userId, tripId); + + // then + assertEquals(1, response.size()); + assertEquals(1000L, response.get(0).getId()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(photoRepository, times(1)).findAllByTripAndAlbumIsNull(trip); + } + + @Test + @DisplayName("getUnassignedPhotos 실패: 조회 권한 없음") + void getUnassignedPhotos_fail_permission() { + // given + Long userId = 1L; + Long tripId = 10L; + + ApiException permissionEx = new ApiException(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS); + when(tripPermissionService.getViewableTrip(userId, tripId)).thenThrow(permissionEx); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> photoService.getUnassignedPhotos(userId, tripId)); + + assertEquals(ErrorCode.UNAUTHORIZED_MEMBER_ACCESS, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(photoRepository, never()).findAllByTripAndAlbumIsNull(any(Trip.class)); + verify(s3Service, never()).createDownloadUrl(anyString()); + } + + @Test + @DisplayName("getPhoto 성공") + void getPhoto_success() { + // given + Long userId = 1L; + Long tripId = 10L; + Long photoId = 1000L; + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + when(photoRepository.findByIdAndTrip(photoId, trip)).thenReturn(Optional.of(photo)); + when(s3Service.createDownloadUrl(photo.getS3Key())).thenReturn("download-url-1"); + + // when + PhotoDetailResponseDto response = photoService.getPhoto(userId, tripId, photoId); + + // then + assertEquals(1000L, response.getId()); + assertEquals("download-url-1", response.getDownloadUrl()); + assertEquals("홍길동", response.getUploaderName()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(photoRepository, times(1)).findByIdAndTrip(photoId, trip); + verify(s3Service, times(1)).createDownloadUrl(photo.getS3Key()); + } + + @Test + @DisplayName("getPhoto 실패: 없는 사진") + void getPhoto_fail_photoNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + Long photoId = 9999L; + + when(tripPermissionService.getViewableTrip(userId, tripId)).thenReturn(trip); + when(photoRepository.findByIdAndTrip(photoId, trip)).thenReturn(Optional.empty()); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> photoService.getPhoto(userId, tripId, photoId)); + + assertEquals(ErrorCode.NOT_FOUND_PHOTO, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getViewableTrip(userId, tripId); + verify(photoRepository, times(1)).findByIdAndTrip(photoId, trip); + verify(s3Service, never()).createDownloadUrl(anyString()); + } + + @Test + @DisplayName("movePhotos 성공") + void movePhotos_success() { + // given + Long userId = 1L; + Long tripId = 10L; + Long targetAlbumId = 100L; + List photoIds = List.of(1000L); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(targetAlbumId, trip)).thenReturn(Optional.of(album)); + when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + + // when + photoService.movePhotos(userId, tripId, targetAlbumId, photoIds); + + // then + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(targetAlbumId, trip); + verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); + verify(photo, times(1)).updateAlbum(album); + } + + @Test + @DisplayName("movePhotos 실패: 사진 개수 불일치") + void movePhotos_fail_photoNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + Long targetAlbumId = 100L; + List photoIds = List.of(1000L, 1001L, 1002L); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(targetAlbumId, trip)).thenReturn(Optional.of(album)); + when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> photoService.movePhotos(userId, tripId, targetAlbumId, photoIds)); + + assertEquals(ErrorCode.NOT_FOUND_PHOTO, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(targetAlbumId, trip); + verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); + } + + @Test + @DisplayName("deletePhotos 성공") + void deletePhotos_success() { + // given + Long userId = 1L; + Long tripId = 10L; + List photoIds = List.of(1000L); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + + // when + photoService.deletePhotos(userId, tripId, photoIds); + + // then + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); + verify(s3Service, times(1)).deleteFile(photo.getS3Key()); + verify(photoRepository, times(1)).deleteAll(List.of(photo)); + } + + @Test + @DisplayName("deletePhotos 실패: 사진 개수 불일치") + void deletePhotos_fail_photoNotFound() { + // given + Long userId = 1L; + Long tripId = 10L; + List photoIds = List.of(1000L, 10001L, 10002L); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + + // when & then + ApiException ex = assertThrows(ApiException.class, + () -> photoService.deletePhotos(userId, tripId, photoIds)); + + assertEquals(ErrorCode.NOT_FOUND_PHOTO, ex.getErrorCode()); + + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); + verify(s3Service, never()).deleteFile(anyString()); + verify(photoRepository, never()).deleteAll(any()); + } +} \ No newline at end of file From e174ab1f2008a59bb5e62a63bf97b0a7e3264bd1 Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 12:23:08 +0900 Subject: [PATCH 05/10] =?UTF-8?q?docs:=20swagger=20docs=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pventure/domain/folder/docs/CreateFolderDocs.java | 11 ++++------- .../pventure/domain/folder/docs/DeleteFolderDocs.java | 7 ++----- .../pventure/domain/folder/docs/GetFolderDocs.java | 7 ++----- .../pventure/domain/folder/docs/GetFoldersDocs.java | 7 ++----- .../pventure/domain/folder/docs/UpdateFolderDocs.java | 7 ++----- .../domain/schedule/docs/GetScheduleDocs.java | 3 +++ .../domain/schedule/docs/GetSchedulesDocs.java | 3 +++ .../domain/schedule/docs/ReorderScheduleDocs.java | 3 +++ .../domain/schedule/docs/UpdateScheduleDocs.java | 3 +++ .../pventure/domain/trip/docs/CreateTripDocs.java | 7 ++----- .../pventure/domain/trip/docs/DeleteTripDocs.java | 7 ++----- .../pventure/domain/trip/docs/GetTripDocs.java | 7 ++----- .../pventure/domain/trip/docs/GetTripsDocs.java | 7 ++----- .../pventure/domain/trip/docs/UpdateTripDocs.java | 9 +++------ .../domain/tripFolder/docs/AddTripToFolderDocs.java | 7 ++----- .../tripFolder/docs/DeleteTripInFolderDocs.java | 7 ++----- .../domain/tripFolder/docs/GetTripsInFolderDocs.java | 7 ++----- 17 files changed, 41 insertions(+), 68 deletions(-) diff --git a/src/main/java/com/example/pventure/domain/folder/docs/CreateFolderDocs.java b/src/main/java/com/example/pventure/domain/folder/docs/CreateFolderDocs.java index bedd325..a76bc8e 100644 --- a/src/main/java/com/example/pventure/domain/folder/docs/CreateFolderDocs.java +++ b/src/main/java/com/example/pventure/domain/folder/docs/CreateFolderDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,9 +12,7 @@ summary = FolderSwaggerDocs.CREATE_SUMMARY, description = FolderSwaggerDocs.CREATE_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청"), - @ApiResponse(responseCode = "409", description = "기본 폴더 중복") -}) -public @interface CreateFolderDocs {} +@ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청") +@ApiResponse(responseCode = "409", description = "기본 폴더 중복") +public @interface CreateFolderDocs {} \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/folder/docs/DeleteFolderDocs.java b/src/main/java/com/example/pventure/domain/folder/docs/DeleteFolderDocs.java index ba97dca..db6701c 100644 --- a/src/main/java/com/example/pventure/domain/folder/docs/DeleteFolderDocs.java +++ b/src/main/java/com/example/pventure/domain/folder/docs/DeleteFolderDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = FolderSwaggerDocs.DELETE_SUMMARY, description = FolderSwaggerDocs.DELETE_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") -}) +@ApiResponse(responseCode = "204", description = "삭제 성공") +@ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") public @interface DeleteFolderDocs {} diff --git a/src/main/java/com/example/pventure/domain/folder/docs/GetFolderDocs.java b/src/main/java/com/example/pventure/domain/folder/docs/GetFolderDocs.java index 449804d..bf27e13 100644 --- a/src/main/java/com/example/pventure/domain/folder/docs/GetFolderDocs.java +++ b/src/main/java/com/example/pventure/domain/folder/docs/GetFolderDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = FolderSwaggerDocs.DETAIL_SUMMARY, description = FolderSwaggerDocs.DETAIL_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") -}) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") public @interface GetFolderDocs {} diff --git a/src/main/java/com/example/pventure/domain/folder/docs/GetFoldersDocs.java b/src/main/java/com/example/pventure/domain/folder/docs/GetFoldersDocs.java index f64f262..c1a549d 100644 --- a/src/main/java/com/example/pventure/domain/folder/docs/GetFoldersDocs.java +++ b/src/main/java/com/example/pventure/domain/folder/docs/GetFoldersDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = FolderSwaggerDocs.GET_ALL_SUMMARY, description = FolderSwaggerDocs.GET_ALL_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "사용자 없음") -}) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "사용자 없음") public @interface GetFoldersDocs {} diff --git a/src/main/java/com/example/pventure/domain/folder/docs/UpdateFolderDocs.java b/src/main/java/com/example/pventure/domain/folder/docs/UpdateFolderDocs.java index 6862258..c8c1091 100644 --- a/src/main/java/com/example/pventure/domain/folder/docs/UpdateFolderDocs.java +++ b/src/main/java/com/example/pventure/domain/folder/docs/UpdateFolderDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = FolderSwaggerDocs.UPDATE_SUMMARY, description = FolderSwaggerDocs.UPDATE_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") -}) +@ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") public @interface UpdateFolderDocs {} diff --git a/src/main/java/com/example/pventure/domain/schedule/docs/GetScheduleDocs.java b/src/main/java/com/example/pventure/domain/schedule/docs/GetScheduleDocs.java index f44d9dd..5c3294a 100644 --- a/src/main/java/com/example/pventure/domain/schedule/docs/GetScheduleDocs.java +++ b/src/main/java/com/example/pventure/domain/schedule/docs/GetScheduleDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import java.lang.annotation.*; @Documented @@ -11,4 +12,6 @@ summary = ScheduleSwaggerDocs.GET_ONE_SUMMARY, description = ScheduleSwaggerDocs.GET_ONE_DESCRIPTION ) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "여행 또는 사용자 없음") public @interface GetScheduleDocs {} diff --git a/src/main/java/com/example/pventure/domain/schedule/docs/GetSchedulesDocs.java b/src/main/java/com/example/pventure/domain/schedule/docs/GetSchedulesDocs.java index a9f9f3c..833a1a8 100644 --- a/src/main/java/com/example/pventure/domain/schedule/docs/GetSchedulesDocs.java +++ b/src/main/java/com/example/pventure/domain/schedule/docs/GetSchedulesDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import java.lang.annotation.*; @Documented @@ -11,4 +12,6 @@ summary = ScheduleSwaggerDocs.GET_BY_DAY_SUMMARY, description = ScheduleSwaggerDocs.GET_BY_DAY_DESCRIPTION ) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "여행 또는 사용자 없음") public @interface GetSchedulesDocs {} diff --git a/src/main/java/com/example/pventure/domain/schedule/docs/ReorderScheduleDocs.java b/src/main/java/com/example/pventure/domain/schedule/docs/ReorderScheduleDocs.java index 0207552..cb9b3df 100644 --- a/src/main/java/com/example/pventure/domain/schedule/docs/ReorderScheduleDocs.java +++ b/src/main/java/com/example/pventure/domain/schedule/docs/ReorderScheduleDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import java.lang.annotation.*; @Documented @@ -11,4 +12,6 @@ summary = ScheduleSwaggerDocs.REORDER_SUMMARY, description = ScheduleSwaggerDocs.REORDER_DESCRIPTION ) +@ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "여행 또는 사용자 또는 일정 없음") public @interface ReorderScheduleDocs {} diff --git a/src/main/java/com/example/pventure/domain/schedule/docs/UpdateScheduleDocs.java b/src/main/java/com/example/pventure/domain/schedule/docs/UpdateScheduleDocs.java index 78eab76..e4f5ac7 100644 --- a/src/main/java/com/example/pventure/domain/schedule/docs/UpdateScheduleDocs.java +++ b/src/main/java/com/example/pventure/domain/schedule/docs/UpdateScheduleDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import java.lang.annotation.*; @Documented @@ -11,4 +12,6 @@ summary = ScheduleSwaggerDocs.UPDATE_SUMMARY, description = ScheduleSwaggerDocs.UPDATE_DESCRIPTION ) +@ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "여행 또는 사용자 또는 일정 없음") public @interface UpdateScheduleDocs {} diff --git a/src/main/java/com/example/pventure/domain/trip/docs/CreateTripDocs.java b/src/main/java/com/example/pventure/domain/trip/docs/CreateTripDocs.java index bd3913b..842785f 100644 --- a/src/main/java/com/example/pventure/domain/trip/docs/CreateTripDocs.java +++ b/src/main/java/com/example/pventure/domain/trip/docs/CreateTripDocs.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -14,8 +13,6 @@ summary = TripSwaggerDocs.CREATE_TRIP_SUMMARY, description = TripSwaggerDocs.CREATE_TRIP_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) -}) +@ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) public @interface CreateTripDocs {} diff --git a/src/main/java/com/example/pventure/domain/trip/docs/DeleteTripDocs.java b/src/main/java/com/example/pventure/domain/trip/docs/DeleteTripDocs.java index a4ba4b3..9215a08 100644 --- a/src/main/java/com/example/pventure/domain/trip/docs/DeleteTripDocs.java +++ b/src/main/java/com/example/pventure/domain/trip/docs/DeleteTripDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = TripSwaggerDocs.DELETE_TRIP_SUMMARY, description = TripSwaggerDocs.DELETE_TRIP_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "404", description = "여행 없음") -}) +@ApiResponse(responseCode = "204", description = "삭제 성공") +@ApiResponse(responseCode = "404", description = "여행 없음") public @interface DeleteTripDocs {} diff --git a/src/main/java/com/example/pventure/domain/trip/docs/GetTripDocs.java b/src/main/java/com/example/pventure/domain/trip/docs/GetTripDocs.java index 906d5e8..431c275 100644 --- a/src/main/java/com/example/pventure/domain/trip/docs/GetTripDocs.java +++ b/src/main/java/com/example/pventure/domain/trip/docs/GetTripDocs.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -14,8 +13,6 @@ summary = TripSwaggerDocs.GET_TRIP_SUMMARY, description = TripSwaggerDocs.GET_TRIP_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "여행 없음", content = @Content) -}) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "여행 없음", content = @Content) public @interface GetTripDocs {} diff --git a/src/main/java/com/example/pventure/domain/trip/docs/GetTripsDocs.java b/src/main/java/com/example/pventure/domain/trip/docs/GetTripsDocs.java index bd60d2b..f3f1f31 100644 --- a/src/main/java/com/example/pventure/domain/trip/docs/GetTripsDocs.java +++ b/src/main/java/com/example/pventure/domain/trip/docs/GetTripsDocs.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -14,8 +13,6 @@ summary = TripSwaggerDocs.GET_TRIPS_SUMMARY, description = TripSwaggerDocs.GET_TRIPS_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) -}) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) public @interface GetTripsDocs {} diff --git a/src/main/java/com/example/pventure/domain/trip/docs/UpdateTripDocs.java b/src/main/java/com/example/pventure/domain/trip/docs/UpdateTripDocs.java index 3bbcc1c..b7fd988 100644 --- a/src/main/java/com/example/pventure/domain/trip/docs/UpdateTripDocs.java +++ b/src/main/java/com/example/pventure/domain/trip/docs/UpdateTripDocs.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -14,9 +13,7 @@ summary = TripSwaggerDocs.UPDATE_TRIP_SUMMARY, description = TripSwaggerDocs.UPDATE_TRIP_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content), - @ApiResponse(responseCode = "404", description = "여행 없음", content = @Content) -}) +@ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) +@ApiResponse(responseCode = "404", description = "여행 없음", content = @Content) public @interface UpdateTripDocs {} diff --git a/src/main/java/com/example/pventure/domain/tripFolder/docs/AddTripToFolderDocs.java b/src/main/java/com/example/pventure/domain/tripFolder/docs/AddTripToFolderDocs.java index afff4fe..1fea5cd 100644 --- a/src/main/java/com/example/pventure/domain/tripFolder/docs/AddTripToFolderDocs.java +++ b/src/main/java/com/example/pventure/domain/tripFolder/docs/AddTripToFolderDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = TripFolderSwaggerDocs.ADD_TRIP_SUMMARY, description = TripFolderSwaggerDocs.ADD_TRIP_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "추가 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "폴더, 여행 또는 사용자 없음") -}) +@ApiResponse(responseCode = "200", description = "추가 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "폴더, 여행 또는 사용자 없음") public @interface AddTripToFolderDocs {} diff --git a/src/main/java/com/example/pventure/domain/tripFolder/docs/DeleteTripInFolderDocs.java b/src/main/java/com/example/pventure/domain/tripFolder/docs/DeleteTripInFolderDocs.java index 417397e..e8da5f4 100644 --- a/src/main/java/com/example/pventure/domain/tripFolder/docs/DeleteTripInFolderDocs.java +++ b/src/main/java/com/example/pventure/domain/tripFolder/docs/DeleteTripInFolderDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = TripFolderSwaggerDocs.DELETE_TRIP_SUMMARY, description = TripFolderSwaggerDocs.DELETE_TRIP_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "404", description = "폴더, 여행 또는 사용자 없음") -}) +@ApiResponse(responseCode = "204", description = "삭제 성공") +@ApiResponse(responseCode = "404", description = "폴더, 여행 또는 사용자 없음") public @interface DeleteTripInFolderDocs {} diff --git a/src/main/java/com/example/pventure/domain/tripFolder/docs/GetTripsInFolderDocs.java b/src/main/java/com/example/pventure/domain/tripFolder/docs/GetTripsInFolderDocs.java index 0e5272c..9987de0 100644 --- a/src/main/java/com/example/pventure/domain/tripFolder/docs/GetTripsInFolderDocs.java +++ b/src/main/java/com/example/pventure/domain/tripFolder/docs/GetTripsInFolderDocs.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import java.lang.annotation.*; @@ -13,8 +12,6 @@ summary = TripFolderSwaggerDocs.GET_TRIPS_SUMMARY, description = TripFolderSwaggerDocs.GET_TRIPS_DESCRIPTION ) -@ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") -}) +@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) +@ApiResponse(responseCode = "404", description = "폴더 또는 사용자 없음") public @interface GetTripsInFolderDocs {} From e129f0dfb23eaa36440942db18f3a3832d156aee Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 12:24:40 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20ScheduleServiceTest=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/schedule/service/ScheduleServiceTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/example/pventure/domain/schedule/service/ScheduleServiceTest.java b/src/test/java/com/example/pventure/domain/schedule/service/ScheduleServiceTest.java index 3b6920d..c01d367 100644 --- a/src/test/java/com/example/pventure/domain/schedule/service/ScheduleServiceTest.java +++ b/src/test/java/com/example/pventure/domain/schedule/service/ScheduleServiceTest.java @@ -34,6 +34,7 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) + class ScheduleServiceTest { @Mock private ScheduleRepository scheduleRepository; @@ -120,9 +121,9 @@ void updateSchedule_success() { when(scheduleRepository.findById(schedule.getId())).thenReturn(Optional.of(schedule)); when(memberService.canEdit(user, trip)).thenReturn(true); - ScheduleUpdateDto dto = new ScheduleUpdateDto(true, "new memo"); + ScheduleUpdateDto dto = new ScheduleUpdateDto("new memo", true); ScheduleResponseDto response = scheduleService.updateSchedule(trip.getId(), schedule.getId(), user.getId(), dto); - assertTrue(response.isCompleted()); + assertTrue(response.getIsCompleted()); } @Test @@ -185,7 +186,7 @@ void updateSchedule_fail_scheduleNotFound() { when(scheduleRepository.findById(999L)).thenReturn(Optional.empty()); when(memberService.canEdit(user, trip)).thenReturn(true); - ScheduleUpdateDto dto = new ScheduleUpdateDto(true, "memo"); + ScheduleUpdateDto dto = new ScheduleUpdateDto("memo", true); ApiException ex = assertThrows(ApiException.class, () -> scheduleService.updateSchedule(trip.getId(), 999L, user.getId(), dto)); assertEquals(ErrorCode.NOT_FOUND_SCHEDULE, ex.getErrorCode()); From 9dea0658b2e5f2be4d7bd9b9b391e9d419e2a0d9 Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 12:26:02 +0900 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20seed=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/dev/V1__insert_seed_data.sql | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/resources/db/migration/dev/V1__insert_seed_data.sql b/src/main/resources/db/migration/dev/V1__insert_seed_data.sql index 37eaa92..23632d8 100644 --- a/src/main/resources/db/migration/dev/V1__insert_seed_data.sql +++ b/src/main/resources/db/migration/dev/V1__insert_seed_data.sql @@ -108,24 +108,24 @@ VALUES -- ===================================================== -- 8. Photo -- ===================================================== -INSERT INTO photo (id, album_id, url, caption, created_at, updated_at) +INSERT INTO photo (id, trip_id, album_id, uploader_id, original_file_name, content_type, file_size, s3key, created_at, updated_at) VALUES -- 서울 - (1, 1, 'https://example.com/photo1.png', '경복궁 입구', NOW(), NOW()), - (2, 1, 'https://example.com/photo2.png', '남산타워 전망', NOW(), NOW()), + (1, 1, 1, 2, 'IMG_01.JPG', 'image/jpeg', 2301234,'trips/1/photos/photo1.png', NOW(), NOW()), + (2, 1, 1, 2, 'IMG_02.JPG', 'image/jpeg', 2301234,'trips/1/photos/photo2.png', NOW(), NOW()), -- 부산 - (3, 2, 'https://example.com/photo3.png', '해운대 해변', NOW(), NOW()), - (4, 2, 'https://example.com/photo4.png', '광안리 야경', NOW(), NOW()), + (3, 2, 2, 1, 'IMG_03.JPG', 'image/jpeg', 2301234,'trips/2/photos/photo3.png', NOW(), NOW()), + (4, 2, 2, 1, 'IMG_04.JPG', 'image/jpeg', 2301234,'trips/2/photos/photo4.png', NOW(), NOW()), -- 제주 - (5, 3, 'https://example.com/photo5.png', '한라산 정상', NOW(), NOW()), - (6, 3, 'https://example.com/photo6.png', '성산일출봉', NOW(), NOW()), - (7, 3, 'https://example.com/photo7.png', '협재 해수욕장', NOW(), NOW()), + (5, 3, 3, 7, 'IMG_05.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo5.png', NOW(), NOW()), + (6, 3, 3, 7, 'IMG_06.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo6.png', NOW(), NOW()), + (7, 3, 3, 7, 'IMG_07.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo7.png', NOW(), NOW()), -- 강릉 - (8, 4, 'https://example.com/photo8.png', '정동진 일출', NOW(), NOW()), - (9, 4, 'https://example.com/photo9.png', '경포대 산책', NOW(), NOW()), + (8, 4, 4, 10, 'IMG_08.JPG', 'image/jpeg', 2301234,'trips/4/photos/photo8.png', NOW(), NOW()), + (9, 4, 4, 10, 'IMG_09.JPG', 'image/jpeg', 2301234,'trips/4/photos/photo9.png', NOW(), NOW()), -- 여수 - (10, 5, 'https://example.com/photo10.png', '오동도 풍경', NOW(), NOW()), - (11, 5, 'https://example.com/photo11.png', '돌산대교 야경', NOW(), NOW()); + (10, 5, 5, 2, 'IMG_10.JPG', 'image/jpeg', 2301234,'trips/5/photos/photo10.png', NOW(), NOW()), + (11, 5, 5, 2, 'IMG_11.JPG', 'image/jpeg', 2301234,'trips/5/photos/photo11.png', NOW(), NOW()); -- ===================================================== -- 9. Member From 624c39083e26c617fef55281bf96664b1ea8765d Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 14:12:48 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/album/dto/query/AlbumWithPhotoCountQDto.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java b/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java index cce5425..555a453 100644 --- a/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java +++ b/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java @@ -1,10 +1,7 @@ package com.example.pventure.domain.album.dto.query; -import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; @Getter @AllArgsConstructor From 7a523d476f6b291c80d6b29d144609ddc1df26bd Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 14:14:17 +0900 Subject: [PATCH 09/10] =?UTF-8?q?QAlbum,=20QPhoto=20=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=84=B4=EC=8A=A4=EB=A5=BC=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../album/repository/AlbumRepositoryCustomImpl.java | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java index 1474c48..cbbe632 100644 --- a/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java +++ b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java @@ -16,11 +16,11 @@ public class AlbumRepositoryCustomImpl implements AlbumRepositoryCustom { private final JPAQueryFactory queryFactory; + private static final QAlbum album = QAlbum.album; + private static final QPhoto photo = QPhoto.photo; + @Override public List findAllByTripWithPhotoCount(Long tripId) { - - QAlbum album = QAlbum.album; - return queryAlbumWithPhotoCount() .where(album.trip.id.eq(tripId)) .fetch(); @@ -28,9 +28,6 @@ public List findAllByTripWithPhotoCount(Long tripId) { @Override public AlbumWithPhotoCountQDto findByIdAndTripWithPhotoCount(Long albumId, Long tripId) { - - QAlbum album = QAlbum.album; - return queryAlbumWithPhotoCount() .where( album.id.eq(albumId), @@ -40,10 +37,6 @@ public AlbumWithPhotoCountQDto findByIdAndTripWithPhotoCount(Long albumId, Long } private JPAQuery queryAlbumWithPhotoCount() { - - QAlbum album = QAlbum.album; - QPhoto photo = QPhoto.photo; - return queryFactory .select(Projections.constructor( AlbumWithPhotoCountQDto.class, From b3a2d4430d4e45f722ea372523a56f8a0629f625 Mon Sep 17 00:00:00 2001 From: khg9900 Date: Thu, 25 Dec 2025 15:32:04 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20coderabbitai=20=EC=BD=94=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../photo/dto/request/PhotoRequestDto.java | 2 + .../dto/request/UploadUrlRequestDto.java | 4 ++ .../dto/response/PhotoDetailResponseDto.java | 6 ++- .../photo/repository/PhotoRepository.java | 10 ++-- .../domain/photo/service/PhotoService.java | 2 +- .../photo/service/PhotoServiceImpl.java | 26 +++++----- .../pventure/global/s3/S3KeyGenerator.java | 27 ++++++++-- .../db/migration/dev/V1__insert_seed_data.sql | 30 +++++------ src/test/http/photo.http | 2 +- .../photo/service/PhotoServiceTest.java | 51 ++++++++++++++----- 11 files changed, 107 insertions(+), 55 deletions(-) diff --git a/build.gradle b/build.gradle index 6e3dbdb..705ee3e 100644 --- a/build.gradle +++ b/build.gradle @@ -79,7 +79,7 @@ dependencies { // ============================================================== // 🪣 S3 // ============================================================== - implementation(platform("software.amazon.awssdk:bom:2.27.21")) + implementation(platform("software.amazon.awssdk:bom:2.40.11")) implementation("software.amazon.awssdk:s3") } diff --git a/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java b/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java index 5c3dba3..a697bfc 100644 --- a/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java +++ b/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java @@ -5,6 +5,7 @@ import com.example.pventure.domain.trip.entity.Trip; import com.example.pventure.domain.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -33,6 +34,7 @@ public class PhotoRequestDto { @Schema(description = "파일 크기", example = "345678") @NotNull(message = "파일 크기를 입력해주세요.") + @Min(value = 1, message = "파일 크기는 1바이트 이상이어야 합니다.") private Long fileSize; public Photo toEntity(Trip trip, Album album, User user) { diff --git a/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java b/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java index 2922408..fd57e22 100644 --- a/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java +++ b/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java @@ -1,8 +1,10 @@ package com.example.pventure.domain.photo.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,10 +21,12 @@ public class UploadUrlRequestDto { @Schema(description = "파일 유형", example = "image/jpeg") @NotBlank(message = "파일 유형을 입력해주세요.") + @Pattern(regexp = "^image/(jpeg|jpg|png|webp)$", message = "지원되는 이미지 형식이 아닙니다.") private String contentType; @Schema(description = "파일 크기(Byte)", example = "345678") @NotNull(message = "파일 크기를 입력해주세요.") + @Min(value = 1, message = "파일 크기는 1바이트 이상이어야 합니다.") private Long fileSize; } \ No newline at end of file diff --git a/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java b/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java index f985189..830c6e0 100644 --- a/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java +++ b/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java @@ -1,8 +1,10 @@ package com.example.pventure.domain.photo.dto.response; import com.example.pventure.domain.photo.entity.Photo; +import com.example.pventure.domain.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +import java.util.Optional; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -43,7 +45,9 @@ public static PhotoDetailResponseDto from (Photo photo, String downloadUrl) { return PhotoDetailResponseDto.builder() .id(photo.getId()) .downloadUrl(downloadUrl) - .uploaderName(photo.getUploader().getName()) + .uploaderName(Optional.ofNullable(photo.getUploader()) + .map(User::getName) + .orElse("Unknown")) .originalFileName(photo.getOriginalFileName()) .contentType(photo.getContentType()) .fileSize(photo.getFileSize()) diff --git a/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java b/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java index 0e3a3e8..bb2e37b 100644 --- a/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java +++ b/src/main/java/com/example/pventure/domain/photo/repository/PhotoRepository.java @@ -5,9 +5,8 @@ import com.example.pventure.domain.trip.entity.Trip; import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; public interface PhotoRepository extends JpaRepository { @@ -15,12 +14,9 @@ public interface PhotoRepository extends JpaRepository { List findAllByTripAndAlbumIsNull(Trip trip); - List findAllByIdIn(List id); - - List findAllByIdInAndTrip(List id, Trip trip); + List findAllByIdInAndTrip(Set id, Trip trip); Optional findByIdAndTrip(Long id, Trip trip); - @Query("SELECT COUNT(*) FROM Photo p WHERE p.album = :album") - Long countByAlbum(@Param("album") Album album); + Long countByAlbum(Album album); } diff --git a/src/main/java/com/example/pventure/domain/photo/service/PhotoService.java b/src/main/java/com/example/pventure/domain/photo/service/PhotoService.java index 58941a9..8c052b2 100644 --- a/src/main/java/com/example/pventure/domain/photo/service/PhotoService.java +++ b/src/main/java/com/example/pventure/domain/photo/service/PhotoService.java @@ -18,7 +18,7 @@ public interface PhotoService { List getUnassignedPhotos(Long userId, Long tripId); - PhotoDetailResponseDto getPhoto(Long userId, Long albumId, Long photoId); + PhotoDetailResponseDto getPhoto(Long userId, Long tripId, Long photoId); void movePhotos(Long userId, Long tripId, Long targetAlbumId, List albumIds); diff --git a/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java b/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java index 87386d0..c45e106 100644 --- a/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java +++ b/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java @@ -18,7 +18,9 @@ import com.example.pventure.global.exception.ErrorCode; import com.example.pventure.global.s3.S3KeyGenerator; import com.example.pventure.global.s3.S3Service; +import java.util.HashSet; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -150,12 +152,7 @@ public void movePhotos(Long userId, Long tripId, Long targetAlbumId, List album = null; } - List photos = photoRepository.findAllByIdInAndTrip(photoIds, trip); - - if (photos.size() != photoIds.size()) { - throw new ApiException(ErrorCode.NOT_FOUND_PHOTO); - } - + List photos = loadPhotos(photoIds, trip); photos.forEach(photo -> photo.updateAlbum(album)); } @@ -168,12 +165,7 @@ public void deletePhotos(Long userId, Long tripId, List photoIds) { Trip trip = tripPermissionService.getEditableTrip(userId, tripId); - List photos = photoRepository.findAllByIdInAndTrip(photoIds, trip); - - if (photos.size() != photoIds.size()) { - throw new ApiException(ErrorCode.NOT_FOUND_PHOTO); - } - + List photos = loadPhotos(photoIds, trip); photos.forEach(photo -> s3Service.deleteFile(photo.getS3Key())); photoRepository.deleteAll(photos); @@ -198,4 +190,14 @@ private Album loadAlbum(Long albumId, Trip trip) { return albumRepository.findByIdAndTrip(albumId, trip) .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND_ALBUM)); } + + private List loadPhotos(List photoIds, Trip trip) { + Set uniqueIds = new HashSet<>(photoIds); + List photos = photoRepository.findAllByIdInAndTrip(uniqueIds, trip); + + if (photos.size() != uniqueIds.size()) { + throw new ApiException(ErrorCode.NOT_FOUND_PHOTO); + } + return photos; + } } diff --git a/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java b/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java index e34961c..a1a0e2b 100644 --- a/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java +++ b/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java @@ -1,31 +1,48 @@ package com.example.pventure.global.s3; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Component; @Component public class S3KeyGenerator { + private static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); + public String generatePhotoKey(Long tripId, String originalFilename) { - String ext = extractExtension(originalFilename); + String ext = extractAndValidateExtension(originalFilename); String uuid = UUID.randomUUID().toString(); return String.format("trips/%d/photos/%s.%s", tripId, uuid, ext); } public String generateTripThumbnailKey(Long tripId, String originalFilename) { - String ext = extractExtension(originalFilename); + String ext = extractAndValidateExtension(originalFilename); String uuid = UUID.randomUUID().toString(); return String.format("trips/%d/thumbnail/%s.%s", tripId, uuid, ext); } public String generateProfileImgKey(Long userId, String originalFilename) { - String ext = extractExtension(originalFilename); + String ext = extractAndValidateExtension(originalFilename); String uuid = UUID.randomUUID().toString(); return String.format("users/%d/profiles/%s.%s", userId, uuid, ext); } - private String extractExtension(String originalFilename) { + private String extractAndValidateExtension(String originalFilename) { + if (originalFilename == null || originalFilename.isBlank()) { + throw new IllegalArgumentException("파일명은 비어 있을 수 없습니다."); + } + int index = originalFilename.lastIndexOf("."); - return originalFilename.substring(index + 1); + if (index == -1 || index == originalFilename.length() - 1) { + throw new IllegalArgumentException("파일 확장자가 존재하지 않습니다."); + } + + String ext = originalFilename.substring(index + 1).toLowerCase(); + + if (!ALLOWED_EXTENSIONS.contains(ext)) { + throw new IllegalArgumentException("허용되지 않은 파일 확장자입니다: " + ext); + } + + return ext; } } diff --git a/src/main/resources/db/migration/dev/V1__insert_seed_data.sql b/src/main/resources/db/migration/dev/V1__insert_seed_data.sql index 23632d8..98674a7 100644 --- a/src/main/resources/db/migration/dev/V1__insert_seed_data.sql +++ b/src/main/resources/db/migration/dev/V1__insert_seed_data.sql @@ -1,14 +1,14 @@ -- 1. Users -- ===================================================== INSERT INTO users (id, name, email, social_provider, provider_id, image_url, created_at, updated_at) -VALUES (1, '테스트 사용자1', 'test1@example.com', 'GOOGLE', 'google_001', 'https://example.com/image1.png', NOW(), NOW()), - (2, '테스트 사용자2', 'test2@example.com', 'NAVER', 'naver_001', 'https://example.com/image2.png', NOW(), NOW()), +VALUES (1, '테스트 사용자1', 'test1@example.com', 'GOOGLE', 'google_001', 'https://example.com/image1.jpg', NOW(), NOW()), + (2, '테스트 사용자2', 'test2@example.com', 'NAVER', 'naver_001', 'https://example.com/image2.jpg', NOW(), NOW()), (3, '테스트 사용자3', 'test3@example.com', 'KAKAO', 'kakao_001', NULL, NOW(), NOW()), (4, '테스트 사용자4', 'test4@example.com', 'KAKAO', 'kakao_002', NULL, NOW(), NOW()), (5, '테스트 사용자5', 'test5@example.com', 'KAKAO', 'kakao_003', NULL, NOW(), NOW()), (6, '테스트 사용자6', 'test6@example.com', 'KAKAO', 'kakao_004', NULL, NOW(), NOW()), - (7, '테스트 사용자7', 'test7@example.com', 'GOOGLE', 'google_007', 'https://example.com/image7.png', NOW(), NOW()), - (8, '테스트 사용자8', 'test8@example.com', 'NAVER', 'naver_008', 'https://example.com/image8.png', NOW(), NOW()), + (7, '테스트 사용자7', 'test7@example.com', 'GOOGLE', 'google_007', 'https://example.com/image7.jpg', NOW(), NOW()), + (8, '테스트 사용자8', 'test8@example.com', 'NAVER', 'naver_008', 'https://example.com/image8.jpg', NOW(), NOW()), (9, '테스트 사용자9', 'test9@example.com', 'KAKAO', 'kakao_009', NULL, NOW(), NOW()), (10, '테스트 사용자10', 'test10@example.com', 'KAKAO', 'kakao_010', NULL, NOW(), NOW()); @@ -111,21 +111,21 @@ VALUES INSERT INTO photo (id, trip_id, album_id, uploader_id, original_file_name, content_type, file_size, s3key, created_at, updated_at) VALUES -- 서울 - (1, 1, 1, 2, 'IMG_01.JPG', 'image/jpeg', 2301234,'trips/1/photos/photo1.png', NOW(), NOW()), - (2, 1, 1, 2, 'IMG_02.JPG', 'image/jpeg', 2301234,'trips/1/photos/photo2.png', NOW(), NOW()), + (1, 1, 1, 2, 'IMG_01.JPG', 'image/jpeg', 2301234,'trips/1/photos/photo1.jpg', NOW(), NOW()), + (2, 1, 1, 2, 'IMG_02.JPG', 'image/jpeg', 2301234,'trips/1/photos/photo2.jpg', NOW(), NOW()), -- 부산 - (3, 2, 2, 1, 'IMG_03.JPG', 'image/jpeg', 2301234,'trips/2/photos/photo3.png', NOW(), NOW()), - (4, 2, 2, 1, 'IMG_04.JPG', 'image/jpeg', 2301234,'trips/2/photos/photo4.png', NOW(), NOW()), + (3, 2, 2, 1, 'IMG_03.JPG', 'image/jpeg', 2301234,'trips/2/photos/photo3.jpg', NOW(), NOW()), + (4, 2, 2, 1, 'IMG_04.JPG', 'image/jpeg', 2301234,'trips/2/photos/photo4.jpg', NOW(), NOW()), -- 제주 - (5, 3, 3, 7, 'IMG_05.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo5.png', NOW(), NOW()), - (6, 3, 3, 7, 'IMG_06.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo6.png', NOW(), NOW()), - (7, 3, 3, 7, 'IMG_07.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo7.png', NOW(), NOW()), + (5, 3, 3, 7, 'IMG_05.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo5.jpg', NOW(), NOW()), + (6, 3, 3, 7, 'IMG_06.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo6.jpg', NOW(), NOW()), + (7, 3, 3, 7, 'IMG_07.JPG', 'image/jpeg', 2301234,'trips/3/photos/photo7.jpg', NOW(), NOW()), -- 강릉 - (8, 4, 4, 10, 'IMG_08.JPG', 'image/jpeg', 2301234,'trips/4/photos/photo8.png', NOW(), NOW()), - (9, 4, 4, 10, 'IMG_09.JPG', 'image/jpeg', 2301234,'trips/4/photos/photo9.png', NOW(), NOW()), + (8, 4, 4, 10, 'IMG_08.JPG', 'image/jpeg', 2301234,'trips/4/photos/photo8.jpg', NOW(), NOW()), + (9, 4, 4, 10, 'IMG_09.JPG', 'image/jpeg', 2301234,'trips/4/photos/photo9.jpg', NOW(), NOW()), -- 여수 - (10, 5, 5, 2, 'IMG_10.JPG', 'image/jpeg', 2301234,'trips/5/photos/photo10.png', NOW(), NOW()), - (11, 5, 5, 2, 'IMG_11.JPG', 'image/jpeg', 2301234,'trips/5/photos/photo11.png', NOW(), NOW()); + (10, 5, 5, 2, 'IMG_10.JPG', 'image/jpeg', 2301234,'trips/5/photos/photo10.jpg', NOW(), NOW()), + (11, 5, 5, 2, 'IMG_11.JPG', 'image/jpeg', 2301234,'trips/5/photos/photo11.jpg', NOW(), NOW()); -- ===================================================== -- 9. Member diff --git a/src/test/http/photo.http b/src/test/http/photo.http index 3f6d0f0..e95128a 100644 --- a/src/test/http/photo.http +++ b/src/test/http/photo.http @@ -41,4 +41,4 @@ Content-Type: application/json } ### 사진 삭제 (Delete Photos) -DELETE http://localhost:8080/api/trips/1?userId=1&photoIds=12 +DELETE http://localhost:8080/api/trips/1/photos?userId=1&photoIds=12 diff --git a/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java b/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java index 2306980..d140d25 100644 --- a/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java +++ b/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -26,13 +27,13 @@ import com.example.pventure.domain.user.repository.UserRepository; import com.example.pventure.global.exception.ApiException; import com.example.pventure.global.exception.ErrorCode; -import com.example.pventure.global.s3.S3KeyGenerator; import com.example.pventure.global.s3.S3Service; import java.lang.reflect.Field; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -103,7 +104,7 @@ private void setId(Object entity, Long id) throws Exception { } @Test - @DisplayName("createPhotos 성공: albumI가 있는 경우") + @DisplayName("createPhotos 성공: albumId가 있는 경우") void createPhotos_success_withAlbum() { // given Long userId = 1L; @@ -381,10 +382,11 @@ void movePhotos_success() { Long tripId = 10L; Long targetAlbumId = 100L; List photoIds = List.of(1000L); + Set uniqueIds = new HashSet<>(photoIds); when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); when(albumRepository.findByIdAndTrip(targetAlbumId, trip)).thenReturn(Optional.of(album)); - when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + when(photoRepository.findAllByIdInAndTrip(eq(uniqueIds), eq(trip))).thenReturn(List.of(photo)); // when photoService.movePhotos(userId, tripId, targetAlbumId, photoIds); @@ -392,8 +394,30 @@ void movePhotos_success() { // then verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); verify(albumRepository, times(1)).findByIdAndTrip(targetAlbumId, trip); - verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); - verify(photo, times(1)).updateAlbum(album); + verify(photoRepository, times(1)).findAllByIdInAndTrip(eq(uniqueIds), eq(trip)); + } + + @Test + @DisplayName("movePhotos 성공: photoIds에 중복이 있어도 정상 처리") + void movePhotos_success_withDuplicateIds() { + // given + Long userId = 1L; + Long tripId = 10L; + Long targetAlbumId = 100L; + List photoIds = List.of(1000L, 1000L); + Set uniqueIds = new HashSet<>(photoIds); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(albumRepository.findByIdAndTrip(targetAlbumId, trip)).thenReturn(Optional.of(album)); + when(photoRepository.findAllByIdInAndTrip(eq(uniqueIds), eq(trip))).thenReturn(List.of(photo)); + + // when + photoService.movePhotos(userId, tripId, targetAlbumId, photoIds); + + // then + verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); + verify(albumRepository, times(1)).findByIdAndTrip(targetAlbumId, trip); + verify(photoRepository, times(1)).findAllByIdInAndTrip(eq(uniqueIds), eq(trip)); } @Test @@ -404,10 +428,11 @@ void movePhotos_fail_photoNotFound() { Long tripId = 10L; Long targetAlbumId = 100L; List photoIds = List.of(1000L, 1001L, 1002L); + Set uniqueIds = new HashSet<>(photoIds); when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); when(albumRepository.findByIdAndTrip(targetAlbumId, trip)).thenReturn(Optional.of(album)); - when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + when(photoRepository.findAllByIdInAndTrip(eq(uniqueIds), eq(trip))).thenReturn(List.of(photo)); // when & then ApiException ex = assertThrows(ApiException.class, @@ -417,7 +442,7 @@ void movePhotos_fail_photoNotFound() { verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); verify(albumRepository, times(1)).findByIdAndTrip(targetAlbumId, trip); - verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); + verify(photoRepository, times(1)).findAllByIdInAndTrip(eq(uniqueIds), eq(trip)); } @Test @@ -427,16 +452,17 @@ void deletePhotos_success() { Long userId = 1L; Long tripId = 10L; List photoIds = List.of(1000L); + Set uniqueIds = new HashSet<>(photoIds); when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); - when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + when(photoRepository.findAllByIdInAndTrip(eq(uniqueIds), eq(trip))).thenReturn(List.of(photo)); // when photoService.deletePhotos(userId, tripId, photoIds); // then verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); - verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); + verify(photoRepository, times(1)).findAllByIdInAndTrip(eq(uniqueIds), eq(trip)); verify(s3Service, times(1)).deleteFile(photo.getS3Key()); verify(photoRepository, times(1)).deleteAll(List.of(photo)); } @@ -448,9 +474,10 @@ void deletePhotos_fail_photoNotFound() { Long userId = 1L; Long tripId = 10L; List photoIds = List.of(1000L, 10001L, 10002L); + Set uniqueIds = new HashSet<>(photoIds); when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); - when(photoRepository.findAllByIdInAndTrip(photoIds, trip)).thenReturn(List.of(photo)); + when(photoRepository.findAllByIdInAndTrip(eq(uniqueIds), eq(trip))).thenReturn(List.of(photo)); // when & then ApiException ex = assertThrows(ApiException.class, @@ -459,7 +486,7 @@ void deletePhotos_fail_photoNotFound() { assertEquals(ErrorCode.NOT_FOUND_PHOTO, ex.getErrorCode()); verify(tripPermissionService, times(1)).getEditableTrip(userId, tripId); - verify(photoRepository, times(1)).findAllByIdInAndTrip(photoIds, trip); + verify(photoRepository, times(1)).findAllByIdInAndTrip(eq(uniqueIds), eq(trip)); verify(s3Service, never()).deleteFile(anyString()); verify(photoRepository, never()).deleteAll(any()); }