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..555a453 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/dto/query/AlbumWithPhotoCountQDto.java @@ -0,0 +1,15 @@ +package com.example.pventure.domain.album.dto.query; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@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..cbbe632 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/album/repository/AlbumRepositoryCustomImpl.java @@ -0,0 +1,51 @@ +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; + + private static final QAlbum album = QAlbum.album; + private static final QPhoto photo = QPhoto.photo; + + @Override + public List findAllByTripWithPhotoCount(Long tripId) { + return queryAlbumWithPhotoCount() + .where(album.trip.id.eq(tripId)) + .fetch(); + } + + @Override + public AlbumWithPhotoCountQDto findByIdAndTripWithPhotoCount(Long albumId, Long tripId) { + return queryAlbumWithPhotoCount() + .where( + album.id.eq(albumId), + album.trip.id.eq(tripId) + ) + .fetchOne(); + } + + private JPAQuery queryAlbumWithPhotoCount() { + 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/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); } 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