diff --git a/build.gradle.kts b/build.gradle.kts index ba51abb1..dc4cc1d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { // AWS S3 implementation("io.awspring.cloud:spring-cloud-aws-starter-s3:3.4.0") + implementation("com.amazonaws:aws-java-sdk-s3:1.12.777") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") diff --git a/src/main/java/com/back/domain/myhistory/dto/MyHistoryLikedPostItemDto.java b/src/main/java/com/back/domain/myhistory/dto/MyHistoryLikedPostItemDto.java index 09e9508e..b973253d 100644 --- a/src/main/java/com/back/domain/myhistory/dto/MyHistoryLikedPostItemDto.java +++ b/src/main/java/com/back/domain/myhistory/dto/MyHistoryLikedPostItemDto.java @@ -1,8 +1,10 @@ package com.back.domain.myhistory.dto; import com.back.domain.post.post.entity.Post; +import com.back.domain.post.post.entity.PostImage; import com.back.domain.post.post.entity.PostLike; import java.time.LocalDateTime; +import java.util.List; import lombok.Builder; import lombok.Getter; @@ -11,7 +13,7 @@ public class MyHistoryLikedPostItemDto { private Long id; private String title; - private String imageUrl; + private List imageUrls; private LocalDateTime likedAt; private Integer likeCount; private Integer commentCount; @@ -21,7 +23,9 @@ public static MyHistoryLikedPostItemDto from(PostLike pl) { return MyHistoryLikedPostItemDto.builder() .id(p.getId()) .title(p.getTitle()) - .imageUrl(p.getImageUrl()) + .imageUrls(p.getImages().stream() + .map(PostImage::getUrl) + .toList()) .likedAt(pl.getCreatedAt()) .likeCount(p.getLikeCount()) .commentCount(p.getCommentCount()) diff --git a/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java b/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java index e12c1247..6c4ecff3 100644 --- a/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java +++ b/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java @@ -1,6 +1,8 @@ package com.back.domain.myhistory.dto; import com.back.domain.post.post.entity.Post; +import com.back.domain.post.post.entity.PostImage; +import java.util.List; import lombok.Builder; import lombok.Getter; @@ -11,7 +13,7 @@ public class MyHistoryPostItemDto { private Long id; private String title; - private String imageUrl; + private List imageUrls; private LocalDateTime createdAt; private Integer likeCount; private Integer commentCount; @@ -20,7 +22,9 @@ public static MyHistoryPostItemDto from(Post p) { return MyHistoryPostItemDto.builder() .id(p.getId()) .title(p.getTitle()) - .imageUrl(p.getImageUrl()) + .imageUrls(p.getImages().stream() + .map(PostImage::getUrl) + .toList()) .createdAt(p.getCreatedAt()) .likeCount(p.getLikeCount()) .commentCount(p.getCommentCount()) diff --git a/src/main/java/com/back/domain/post/post/controller/PostController.java b/src/main/java/com/back/domain/post/post/controller/PostController.java index 1e2230fe..e464ca38 100644 --- a/src/main/java/com/back/domain/post/post/controller/PostController.java +++ b/src/main/java/com/back/domain/post/post/controller/PostController.java @@ -8,10 +8,19 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/posts") @@ -23,15 +32,18 @@ public class PostController { /** * 게시글 작성 API + * * @param reqBody 게시글 작성 요청 DTO + * @param images 첨부 이미지 파일들 (optional) * @return 작성된 게시글 정보 */ - @PostMapping + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "게시글 작성") public RsData createPost( - @Valid @RequestBody PostCreateRequestDto reqBody + @RequestPart("post") @Valid PostCreateRequestDto reqBody, + @RequestPart(value = "images", required = false) List images ) { - return RsData.successOf(postService.createPost(reqBody)); // code=200, message="success" + return RsData.successOf(postService.createPost(reqBody, images)); // code=200, message="success" } /** @@ -66,13 +78,14 @@ public RsData getPost( * @param reqBody 게시글 수정 요청 DTO * @return 수정된 게시글 정보 */ - @PatchMapping("/{postId}") + @PatchMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "게시글 수정") public RsData updatePost( @PathVariable Long postId, - @Valid @RequestBody PostUpdateRequestDto reqBody + @RequestPart("post") @Valid PostUpdateRequestDto reqBody, + @RequestPart(value = "images", required = false) List images ) { - return RsData.successOf(postService.updatePost(postId, reqBody)); // code=200, message="success" + return RsData.successOf(postService.updatePost(postId, reqBody, images)); // code=200, message="success" } /** diff --git a/src/main/java/com/back/domain/post/post/dto/request/PostCreateRequestDto.java b/src/main/java/com/back/domain/post/post/dto/request/PostCreateRequestDto.java index 54940013..8a20aade 100644 --- a/src/main/java/com/back/domain/post/post/dto/request/PostCreateRequestDto.java +++ b/src/main/java/com/back/domain/post/post/dto/request/PostCreateRequestDto.java @@ -11,7 +11,6 @@ public record PostCreateRequestDto( String title, @NotBlank (message = "내용은 필수입니다.") String content, - String imageUrl, String videoUrl, List tags ) { diff --git a/src/main/java/com/back/domain/post/post/dto/request/PostUpdateRequestDto.java b/src/main/java/com/back/domain/post/post/dto/request/PostUpdateRequestDto.java index 65d97457..87c2e119 100644 --- a/src/main/java/com/back/domain/post/post/dto/request/PostUpdateRequestDto.java +++ b/src/main/java/com/back/domain/post/post/dto/request/PostUpdateRequestDto.java @@ -8,7 +8,8 @@ public record PostUpdateRequestDto( PostStatus status, String title, String content, - String imageUrl, + // 기존 이미지 중 유지할 이미지 ID 목록 + List keepImageIds, String videoUrl, List tags ) { diff --git a/src/main/java/com/back/domain/post/post/dto/response/PostResponseDto.java b/src/main/java/com/back/domain/post/post/dto/response/PostResponseDto.java index 4f06c3a7..d788d1a2 100644 --- a/src/main/java/com/back/domain/post/post/dto/response/PostResponseDto.java +++ b/src/main/java/com/back/domain/post/post/dto/response/PostResponseDto.java @@ -1,6 +1,7 @@ package com.back.domain.post.post.dto.response; import com.back.domain.post.post.entity.Post; +import com.back.domain.post.post.entity.PostImage; import com.back.domain.post.post.enums.PostStatus; import java.time.LocalDateTime; import java.util.List; @@ -14,7 +15,7 @@ public record PostResponseDto( PostStatus status, String title, String content, - String imageUrl, + List imageUrls, String videoUrl, List tags, Integer likeCount, @@ -32,7 +33,9 @@ public PostResponseDto(Post post) { post.getStatus(), post.getTitle(), post.getContent(), - post.getImageUrl(), + post.getImages().stream() + .map(PostImage::getUrl) + .toList(), post.getVideoUrl(), post.getPostTags().stream() .map(postTag -> postTag.getTag().getName()) diff --git a/src/main/java/com/back/domain/post/post/entity/Post.java b/src/main/java/com/back/domain/post/post/entity/Post.java index a7198225..c4769d85 100644 --- a/src/main/java/com/back/domain/post/post/entity/Post.java +++ b/src/main/java/com/back/domain/post/post/entity/Post.java @@ -1,6 +1,7 @@ package com.back.domain.post.post.entity; import com.back.domain.post.category.entity.Category; +import com.back.domain.post.comment.entity.Comment; import com.back.domain.post.post.enums.PostStatus; import com.back.domain.user.entity.User; import jakarta.persistence.*; @@ -62,9 +63,14 @@ public class Post { @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; - // 게시글 이미지 URL - @Column(name = "image_url") - private String imageUrl; + // Post → Comment = 1:N + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List comments = new ArrayList<>(); + + // Post → PostImage = 1:N + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC") // 조회 시 순서대로 정렬 + private List images = new ArrayList<>(); // 게시글 동영상 URL @Column(name = "video_url") @@ -105,8 +111,12 @@ public void updateContent(String content) { this.content = content; } - public void updateImage(String imageUrl) { - this.imageUrl = imageUrl; + public void updateImages(List images) { + this.images.clear(); + for (PostImage i : images) { + i.updatePost(this); + this.images.add(i); + } } public void updateVideo(String videoUrl) { diff --git a/src/main/java/com/back/domain/post/post/entity/PostImage.java b/src/main/java/com/back/domain/post/post/entity/PostImage.java new file mode 100644 index 00000000..ab06a0f1 --- /dev/null +++ b/src/main/java/com/back/domain/post/post/entity/PostImage.java @@ -0,0 +1,65 @@ +package com.back.domain.post.post.entity; + +import com.back.domain.post.post.enums.PostImageStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Getter +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PostImage { + + // 각 이미지를 구분하는 유일한 번호 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 해당 이미지가 첨부된 게시글의 고유 식별자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + // 이미지 파일 명 + @Column(name = "file_name") + private String fileName; + + // 이미지 URL + @Column(name = "url") + private String url; + + // 이미지 삭제 상태 (기본값: 게시) + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PostImageStatus status = PostImageStatus.POSTED; + + // 이미지 순서 + @Column(name = "sort_order") + private Integer sortOrder; + + public void updatePost(Post post) { + this.post = post; + } + + public void updateSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/src/main/java/com/back/domain/post/post/enums/PostImageStatus.java b/src/main/java/com/back/domain/post/post/enums/PostImageStatus.java new file mode 100644 index 00000000..a593231f --- /dev/null +++ b/src/main/java/com/back/domain/post/post/enums/PostImageStatus.java @@ -0,0 +1,14 @@ +package com.back.domain.post.post.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PostImageStatus { + POSTED("게시", "이미지가 해당 게시물에 게시된 상태"), + DELETED("삭제", "이미지가 삭제 처리된 상태"); + + private final String title; + private final String description; +} diff --git a/src/main/java/com/back/domain/post/post/repository/PostImageRepository.java b/src/main/java/com/back/domain/post/post/repository/PostImageRepository.java new file mode 100644 index 00000000..5e61cf17 --- /dev/null +++ b/src/main/java/com/back/domain/post/post/repository/PostImageRepository.java @@ -0,0 +1,8 @@ +package com.back.domain.post.post.repository; + +import com.back.domain.post.post.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostImageRepository extends JpaRepository { + +} diff --git a/src/main/java/com/back/domain/post/post/service/PostService.java b/src/main/java/com/back/domain/post/post/service/PostService.java index 03552e7f..07d11bf2 100644 --- a/src/main/java/com/back/domain/post/post/service/PostService.java +++ b/src/main/java/com/back/domain/post/post/service/PostService.java @@ -8,23 +8,33 @@ import com.back.domain.post.post.dto.request.PostUpdateRequestDto; import com.back.domain.post.post.dto.response.PostResponseDto; import com.back.domain.post.post.entity.Post; +import com.back.domain.post.post.entity.PostImage; import com.back.domain.post.post.entity.PostLike; import com.back.domain.post.post.entity.Tag; import com.back.domain.post.post.enums.PostLikeStatus; import com.back.domain.post.post.enums.PostStatus; +import com.back.domain.post.post.repository.PostImageRepository; import com.back.domain.post.post.repository.PostLikeRepository; import com.back.domain.post.post.repository.PostRepository; import com.back.domain.post.post.repository.TagRepository; import com.back.domain.user.entity.User; -import com.back.global.rq.Rq; import com.back.domain.user.service.AbvScoreService; +import com.back.global.file.dto.UploadedFileDto; +import com.back.global.file.service.FileService; +import com.back.global.rq.Rq; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -34,42 +44,60 @@ public class PostService { private final CategoryRepository categoryRepository; private final TagRepository tagRepository; private final PostLikeRepository postLikeRepository; + private final PostImageRepository postImageRepository; private final NotificationService notificationService; private final Rq rq; private final AbvScoreService abvScoreService; + private final FileService fileService; // 게시글 작성 로직 @Transactional - public PostResponseDto createPost(PostCreateRequestDto reqBody) { + public PostResponseDto createPost(PostCreateRequestDto reqBody, List images) { User user = rq.getActor(); // 현재 로그인한 사용자의 정보 가져오기 Category category = categoryRepository.findById(reqBody.categoryId()) .orElseThrow(() -> new IllegalArgumentException("해당 카테고리를 찾을 수 없습니다. ID: " + reqBody.categoryId())); + // 게시글 엔티티 생성 (태그와 이미지 제외) Post post = Post.builder() .category(category) .user(user) .title(reqBody.title()) .content(reqBody.content()) - .imageUrl(reqBody.imageUrl()) .videoUrl(reqBody.videoUrl()) .build(); + // 태그 저장 List tagNames = reqBody.tags(); if (tagNames != null && !tagNames.isEmpty()) { addTag(tagNames, post); } - Post saved = postRepository.save(post); + // 이미지 저장 (S3 업로드 + DB 저장) + if (images != null && !images.isEmpty()) { + int order = 0; + for (MultipartFile image : images) { + String url = fileService.uploadFile(image); + + PostImage postImage = PostImage.builder() + .post(post) + .fileName(image.getOriginalFilename()) + .url(url) + .sortOrder(order++) + .build(); + + postImageRepository.save(postImage); + } + } + // 활동 점수: 게시글 작성 +0.5 abvScoreService.awardForPost(user.getId()); - return new PostResponseDto(saved); + return new PostResponseDto(postRepository.save(post)); } // 게시글 다건 조회 로직 @Transactional(readOnly = true) public List getAllPosts(Long lastId) { -// List posts = postRepository.findAll(); List posts; if (lastId == null) { @@ -96,7 +124,7 @@ public PostResponseDto getPost(Long postId) { // 게시글 수정 로직 @Transactional - public PostResponseDto updatePost(Long postId, PostUpdateRequestDto reqBody) { + public PostResponseDto updatePost(Long postId, PostUpdateRequestDto reqBody, List images) { Post post = postRepository.findById(postId) .orElseThrow(() -> new NoSuchElementException("해당 게시글을 찾을 수 없습니다. ID: " + postId)); @@ -117,18 +145,69 @@ public PostResponseDto updatePost(Long postId, PostUpdateRequestDto reqBody) { if (reqBody.content() != null && !reqBody.content().isBlank()) { post.updateContent(reqBody.content()); } - if (reqBody.imageUrl() != null && !reqBody.imageUrl().isBlank()) { - post.updateImage(reqBody.imageUrl()); + if (images != null && !images.isEmpty()) { + // 새 이미지 업로드 + List uploaded = fileService.uploadFiles(images); + List uploadedFileNames = uploaded.stream().map(UploadedFileDto::fileName).toList(); + + // 요청 DTO에서 "유지할 이미지 ID 목록" 꺼내기 + List keepIds = Optional.ofNullable(reqBody.keepImageIds()).orElse(List.of()); + + // 현재 게시글의 이미지들을 (id -> 객체) 매핑으로 변환 + Map existingById = post.getImages().stream() + .collect(Collectors.toMap(PostImage::getId, Function.identity())); + + // 삭제할 이미지 찾기 + List toDelete = post.getImages().stream() + .filter(img -> !keepIds.contains(img.getId())) + .toList(); + + // 최종 이미지 리스트 구성 + List finalImages = new ArrayList<>(); + int order = 0; + for (Long keepId : keepIds) { + PostImage img = existingById.get(keepId); + if (img != null) { + img.updateSortOrder(order++); + finalImages.add(img); + } + } + for (UploadedFileDto u : uploaded) { + finalImages.add(PostImage.builder() + .post(post) + .fileName(u.fileName()) + .url(u.url()) + .sortOrder(order++) + .build() + ); + } + + // 삭제 예정 key 모음 + List deleteKeysAfterCommit = toDelete.stream() + .map(PostImage::getFileName) + .toList(); + + // DB에 반영 + post.updateImages(finalImages); + + // 트랜잭션 완료 후 처리 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_ROLLED_BACK) { + uploadedFileNames.forEach(fileService::deleteFile); + } else if (status == STATUS_COMMITTED) { + deleteKeysAfterCommit.forEach(fileService::deleteFile); + } + } + }); } if (reqBody.videoUrl() != null && !reqBody.videoUrl().isBlank()) { post.updateVideo(reqBody.videoUrl()); } if (reqBody.tags() != null) { - // 기존 태그들 삭제 - post.clearTags(); - - // 새로운 태그들 추가 - addTag(reqBody.tags(), post); + post.clearTags(); // 기존 태그들 삭제 + addTag(reqBody.tags(), post); // 새로운 태그들 추가 } return new PostResponseDto(post); diff --git a/src/main/java/com/back/global/appConfig/S3Config.java b/src/main/java/com/back/global/appConfig/S3Config.java new file mode 100644 index 00000000..1e12143d --- /dev/null +++ b/src/main/java/com/back/global/appConfig/S3Config.java @@ -0,0 +1,30 @@ +package com.back.global.appConfig; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${spring.cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${spring.cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${spring.cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .build(); + } +} diff --git a/src/main/java/com/back/global/file/Controller/FileController.java b/src/main/java/com/back/global/file/Controller/FileController.java new file mode 100644 index 00000000..3f7f2630 --- /dev/null +++ b/src/main/java/com/back/global/file/Controller/FileController.java @@ -0,0 +1,74 @@ +package com.back.global.file.Controller; + +import com.back.global.file.service.FileService; +import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Bucket; + + +@Tag(name = "File", description = "file API") +@RestController +@RequestMapping("/file") +@RequiredArgsConstructor +public class FileController { + + private final S3Client s3Client; + private final FileService fileService; + + @Operation(summary = "S3 버킷 목록 조회", description = "모든 버킷 목록을 조회") + @GetMapping("/buckets") + public RsData> listBuckets() { + + return RsData.of( + 200, + "버킷 목록 조회", + s3Client + .listBuckets() + .buckets() + .stream() + .map(Bucket::name) + .collect(Collectors.toList()) + ); + } + + /** + * 개별 파일 업로드 API + * @param file 업로드할 파일 + * @return 업로드 결과 및 파일 URL + */ + @PostMapping("/upload") + @Operation(summary = "개별 파일 업로드") + public RsData uploadFile( + @RequestParam("file") MultipartFile file + ) { + return RsData.successOf( + "개별 파일 업로드를 성공했습니다. 파일URL: " + fileService.uploadFile(file) + ); + } + + /** + * 파일 삭제 API + * @param fileName 삭제할 파일 이름 + * @return 삭제 결과 + */ + @DeleteMapping + @Operation(summary = "파일 삭제") + public RsData deleteFile( + @RequestParam String fileName + ){ + fileService.deleteFile(fileName); + return RsData.successOf(null); + } +} diff --git a/src/main/java/com/back/global/file/FileController.java b/src/main/java/com/back/global/file/FileController.java deleted file mode 100644 index 44d7c666..00000000 --- a/src/main/java/com/back/global/file/FileController.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.back.global.file; - -import com.back.global.rsData.RsData; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.Bucket; - -import java.util.List; -import java.util.stream.Collectors; - - -@Tag(name = "File", description = "file API") -@RestController -@RequestMapping("/file") -@RequiredArgsConstructor -public class FileController { - private final S3Client s3Client; - - @Operation(summary = "S3 버킷 목록 조회", description = "모든 버킷 목록을 조회") - @GetMapping("/buckets") - public RsData> listBuckets() { - - return RsData.of( - 200, - "버킷 목록 조회", - s3Client - .listBuckets() - .buckets() - .stream() - .map(Bucket::name) - .collect(Collectors.toList()) - ); - - } -} diff --git a/src/main/java/com/back/global/file/dto/UploadedFileDto.java b/src/main/java/com/back/global/file/dto/UploadedFileDto.java new file mode 100644 index 00000000..fd8785a1 --- /dev/null +++ b/src/main/java/com/back/global/file/dto/UploadedFileDto.java @@ -0,0 +1,7 @@ +package com.back.global.file.dto; + +public record UploadedFileDto( + String fileName, + String url +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/global/file/service/FileService.java b/src/main/java/com/back/global/file/service/FileService.java new file mode 100644 index 00000000..25ef2f21 --- /dev/null +++ b/src/main/java/com/back/global/file/service/FileService.java @@ -0,0 +1,96 @@ +package com.back.global.file.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.back.global.file.dto.UploadedFileDto; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class FileService { + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + private final AmazonS3 amazonS3; + + public List uploadFiles(List files) { + if (files == null) return List.of(); + + List results = new ArrayList<>(); + for (MultipartFile file : files) { + // 난수를 이용한 고유한 파일 이름 생성 + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + + try { + // 메타데이터 설정 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(file.getContentType()); + metadata.setContentLength(file.getSize()); + + // S3에 파일 업로드 요청 생성 및 업로드 + amazonS3.putObject(new PutObjectRequest( + bucket, + fileName, + file.getInputStream(), + metadata + ) + // 업로드하는 파일의 접근 권한(ACL, Access Control List) 을 미리 정의된(Canned) 설정으로 지정 + // PublicRead : 누구나 읽기 가능(브라우저에서 URL만 알면 열람 가능) + // .withCannedAcl(CannedAccessControlList.PublicRead) + ); + // S3에 업로드된 파일에 접근 가능한 URL을 문자열로 반환 + String url = amazonS3.getUrl(bucket, fileName).toString(); + + results.add(new UploadedFileDto(fileName, url)); + } catch (IOException e) { + throw new RuntimeException("S3 업로드 실패", e); + } + } + return results; + } + + // 개별 파일 업로드 로직 + public String uploadFile(MultipartFile file) { + // 난수를 이용한 고유한 파일 이름 생성 + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + + try { + // 메타데이터 설정 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(file.getContentType()); + metadata.setContentLength(file.getSize()); + + // S3에 파일 업로드 요청 생성 및 업로드 + amazonS3.putObject(new PutObjectRequest( + bucket, + fileName, + file.getInputStream(), + metadata + ) + // 업로드하는 파일의 접근 권한(ACL, Access Control List) 을 미리 정의된(Canned) 설정으로 지정 + // PublicRead : 누구나 읽기 가능(브라우저에서 URL만 알면 열람 가능) +// .withCannedAcl(CannedAccessControlList.PublicRead) + ); + + } catch (IOException e) { + throw new RuntimeException("S3 파일 업로드 실패", e); + } + // S3에 업로드된 파일에 접근 가능한 URL을 문자열로 반환 + return amazonS3.getUrl(bucket, fileName).toString(); + } + + // 파일 삭제 로직 + public void deleteFile(String fileName){ + amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); + } +} diff --git a/src/main/java/com/back/global/init/DevInitData.java b/src/main/java/com/back/global/init/DevInitData.java index 6101f7e7..a36d2a5f 100644 --- a/src/main/java/com/back/global/init/DevInitData.java +++ b/src/main/java/com/back/global/init/DevInitData.java @@ -10,8 +10,10 @@ import com.back.domain.post.comment.entity.Comment; import com.back.domain.post.comment.repository.CommentRepository; import com.back.domain.post.post.entity.Post; +import com.back.domain.post.post.entity.PostImage; import com.back.domain.post.post.entity.PostLike; import com.back.domain.post.post.enums.PostLikeStatus; +import com.back.domain.post.post.repository.PostImageRepository; import com.back.domain.post.post.repository.PostLikeRepository; import com.back.domain.post.post.repository.PostRepository; import com.back.domain.user.entity.User; @@ -35,6 +37,7 @@ public class DevInitData { private final PostRepository postRepository; private final CommentRepository commentRepository; private final PostLikeRepository postLikeRepository; + private final PostImageRepository postImageRepository; private final NotificationRepository notificationRepository; private final CocktailRepository cocktailRepository; private final com.back.domain.mybar.service.MyBarService myBarService; @@ -89,7 +92,7 @@ public void boardInit() { .user(userA) .title("A의 게시글") .content("내용A") - .imageUrl("/img/cocktail/1.jpg") + .videoUrl("/img/cocktail/1.jpg") .build()); Post postB = postRepository.save(Post.builder() @@ -97,7 +100,7 @@ public void boardInit() { .user(userB) .title("B의 게시글") .content("내용B") - .imageUrl("/img/cocktail/2.jpg") + .videoUrl("/img/cocktail/2.jpg") .build()); // 댓글: C가 A/B 게시글에 작성 @@ -142,6 +145,22 @@ public void boardInit() { .build()); postB.increaseLikeCount(); + postImageRepository.save(PostImage.builder() + .post(postA) + .fileName("1.jpg") + .url("/img/cocktail/1.jpg") + .sortOrder(1) + .build() + ); + + postImageRepository.save(PostImage.builder() + .post(postB) + .fileName("2.jpg") + .url("/img/cocktail/2.jpg") + .sortOrder(1) + .build() + ); + postRepository.save(postA); postRepository.save(postB); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 28ebf512..2afbc2fe 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -36,8 +36,10 @@ spring: stack: auto: false credentials: - access-key: ${AWS_ACCESS_KEY_ID:dummy} # 로컬용 더미값 - secret-key: ${AWS_SECRET_ACCESS_KEY:dummy} + access-key: ${AWS_ACCESS_KEY_ID} # 로컬용 더미값 + secret-key: ${AWS_SECRET_ACCESS_KEY} + s3: + bucket: team2-app-s3-bucket # Swagger 설정 springdoc: diff --git a/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java b/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java index a6b8ffa7..41d0928b 100644 --- a/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java +++ b/src/test/java/com/back/domain/post/post/controller/PostControllerTest.java @@ -59,7 +59,10 @@ private PostResponseDto createSampleResponseDto(Long id) { PostStatus.PUBLIC, "테스트 제목" + id, "테스트 내용" + id, - "http://example.com/image.jpg", + List.of( + "http://example.com/image1.jpg", + "http://example.com/image2.jpg" + ), // 이미지 리스트 "http://example.com/video.mp4", List.of("태그1", "태그2"), 0, // likeCount @@ -77,12 +80,11 @@ void createPost() throws Exception { PostStatus.PUBLIC, "테스트 제목1", "테스트 내용1", - "http://example.com/image1.jpg", "http://example.com/video1.mp4", List.of("태그1", "태그2") ); PostResponseDto responseDto = createSampleResponseDto(1L); - given(postService.createPost(any(PostCreateRequestDto.class))).willReturn(responseDto); + given(postService.createPost(any(PostCreateRequestDto.class), any(null))).willReturn(responseDto); // when & then mockMvc.perform(post("/posts") @@ -193,7 +195,10 @@ void updatePost() throws Exception { PostStatus.PUBLIC, "수정된 제목" + postId, "수정된 내용" + postId, - "http://example.com/image.jpg", + List.of( + 1L, + 2L + ), // 이미지 리스트 "http://example.com/video.mp4", List.of("태그1", "태그2") ); @@ -206,14 +211,17 @@ void updatePost() throws Exception { PostStatus.PUBLIC, requestDto.title(), requestDto.content(), - "http://example.com/image.jpg", + List.of( + "http://example.com/image1.jpg", + "http://example.com/image2.jpg" + ), // 이미지 리스트 "http://example.com/video.mp4", List.of("태그1", "태그2"), 0, // likeCount 0, // commentCount 0 // viewCount ); - given(postService.updatePost(eq(1L), any(PostUpdateRequestDto.class))).willReturn(responseDto); + given(postService.updatePost(eq(1L), any(PostUpdateRequestDto.class), any(null))).willReturn(responseDto); // when & then mockMvc.perform(patch("/posts/{postId}", postId)