diff --git a/build.gradle b/build.gradle index 6cc754f..705ee3e 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.40.11")) + implementation("software.amazon.awssdk:s3") } sourceSets { 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/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..a697bfc --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/request/PhotoRequestDto.java @@ -0,0 +1,51 @@ +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.Min; +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 = "파일 크기λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Min(value = 1, message = "파일 ν¬κΈ°λŠ” 1λ°”μ΄νŠΈ 이상이어야 ν•©λ‹ˆλ‹€.") + 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..fd57e22 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/request/UploadUrlRequestDto.java @@ -0,0 +1,32 @@ +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; + +@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 = "파일 μœ ν˜•μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @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 new file mode 100644 index 0000000..830c6e0 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/dto/response/PhotoDetailResponseDto.java @@ -0,0 +1,57 @@ +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; +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(Optional.ofNullable(photo.getUploader()) + .map(User::getName) + .orElse("Unknown")) + .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..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 @@ -1,7 +1,22 @@ 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 java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; public interface PhotoRepository extends JpaRepository { + + List findAllByAlbum(Album album); + + List findAllByTripAndAlbumIsNull(Trip trip); + + List findAllByIdInAndTrip(Set id, Trip trip); + + Optional findByIdAndTrip(Long id, Trip trip); + + Long countByAlbum(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..8c052b2 --- /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 tripId, 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..c45e106 --- /dev/null +++ b/src/main/java/com/example/pventure/domain/photo/service/PhotoServiceImpl.java @@ -0,0 +1,203 @@ +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.HashSet; +import java.util.List; +import java.util.Set; +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 = loadPhotos(photoIds, trip); + 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 = loadPhotos(photoIds, trip); + 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)); + } + + 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/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/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 {} 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..a1a0e2b --- /dev/null +++ b/src/main/java/com/example/pventure/global/s3/S3KeyGenerator.java @@ -0,0 +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 = 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 = 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 = extractAndValidateExtension(originalFilename); + String uuid = UUID.randomUUID().toString(); + return String.format("users/%d/profiles/%s.%s", userId, uuid, ext); + } + + private String extractAndValidateExtension(String originalFilename) { + if (originalFilename == null || originalFilename.isBlank()) { + throw new IllegalArgumentException("파일λͺ…은 λΉ„μ–΄ μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + int index = originalFilename.lastIndexOf("."); + 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/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 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..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()); @@ -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.jpg', NOW(), NOW()), + (2, 1, 1, 2, 'IMG_02.JPG', 'image/jpeg', 2301234,'trips/1/photos/photo2.jpg', 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.jpg', NOW(), NOW()), + (4, 2, 2, 1, 'IMG_04.JPG', 'image/jpeg', 2301234,'trips/2/photos/photo4.jpg', 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.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, '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.jpg', NOW(), NOW()), + (9, 4, 4, 10, 'IMG_09.JPG', 'image/jpeg', 2301234,'trips/4/photos/photo9.jpg', 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.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 new file mode 100644 index 0000000..e95128a --- /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/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 new file mode 100644 index 0000000..d140d25 --- /dev/null +++ b/src/test/java/com/example/pventure/domain/photo/service/PhotoServiceTest.java @@ -0,0 +1,493 @@ +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.ArgumentMatchers.eq; +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.S3Service; +import java.lang.reflect.Field; +import java.time.LocalDate; +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; +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 성곡: albumIdκ°€ μžˆλŠ” 경우") + 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); + 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 + @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 + @DisplayName("movePhotos μ‹€νŒ¨: 사진 개수 뢈일치") + void movePhotos_fail_photoNotFound() { + // given + Long userId = 1L; + 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(eq(uniqueIds), eq(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(eq(uniqueIds), eq(trip)); + } + + @Test + @DisplayName("deletePhotos 성곡") + void deletePhotos_success() { + // given + 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(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(eq(uniqueIds), eq(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); + Set uniqueIds = new HashSet<>(photoIds); + + when(tripPermissionService.getEditableTrip(userId, tripId)).thenReturn(trip); + when(photoRepository.findAllByIdInAndTrip(eq(uniqueIds), eq(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(eq(uniqueIds), eq(trip)); + verify(s3Service, never()).deleteFile(anyString()); + verify(photoRepository, never()).deleteAll(any()); + } +} \ No newline at end of file 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());