From 9ab3fd1aa74fce719009b51848c69f34f8231729 Mon Sep 17 00:00:00 2001 From: LeeDongHoon Date: Fri, 1 Aug 2025 21:52:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccompanyCommentController.java | 2 +- .../controller/AccompanyController.java | 12 +--- .../dto/request/AccompanyPostRequest.java | 13 ++-- .../accompanies/service/AccompanyService.java | 23 ++++--- .../swagger/GetAccompaniesBrief.java | 6 +- .../arom/with_travel/domain/image/Image.java | 40 ++++++++++- .../with_travel/domain/image/ImageType.java | 5 ++ .../domain/image/PostUploadImages.java | 69 +++++++++++++++++++ .../image/controller/ImageController.java | 21 ++++-- .../domain/image/dto/ImageRequest.java | 9 +++ .../image/dto/UploadedImageResponse.java | 11 +++ .../image/repository/ImageRepository.java | 7 ++ .../with_travel/domain/member/Member.java | 8 ++- .../controller/MemberProfileController.java | 13 ++-- .../member/dto/MemberProfileResponseDto.java | 3 - .../member/service/MemberProfileService.java | 10 +++ .../global/exception/error/ErrorCode.java | 7 +- .../global/s3/properties/S3Properties.java | 10 +++ .../global/s3/service/S3Service.java | 53 +++++++++----- .../member/service/MemberProfileTest.java | 2 - 20 files changed, 253 insertions(+), 71 deletions(-) create mode 100644 src/main/java/com/arom/with_travel/domain/image/ImageType.java create mode 100644 src/main/java/com/arom/with_travel/domain/image/PostUploadImages.java create mode 100644 src/main/java/com/arom/with_travel/domain/image/dto/ImageRequest.java create mode 100644 src/main/java/com/arom/with_travel/domain/image/dto/UploadedImageResponse.java create mode 100644 src/main/java/com/arom/with_travel/domain/image/repository/ImageRepository.java diff --git a/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyCommentController.java b/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyCommentController.java index 55d3589..3932c08 100644 --- a/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyCommentController.java +++ b/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyCommentController.java @@ -21,7 +21,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/accompanies/{accompanyId}/comments") -@Tag(name = "동행 후기 CRUD") +@Tag(name = "동행 후기", description = "동행 후기 글 CRUD api") public class AccompanyCommentController { private final AccompanyCommentService accompanyCommentService; diff --git a/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyController.java b/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyController.java index cf3bdc6..9dca23f 100644 --- a/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyController.java +++ b/src/main/java/com/arom/with_travel/domain/accompanies/controller/AccompanyController.java @@ -4,8 +4,6 @@ import com.arom.with_travel.domain.accompanies.dto.response.AccompanyBriefResponse; import com.arom.with_travel.domain.accompanies.dto.response.AccompanyDetailsResponse; import com.arom.with_travel.domain.accompanies.dto.response.CursorSliceResponse; -import com.arom.with_travel.domain.accompanies.model.City; -import com.arom.with_travel.domain.accompanies.model.Continent; import com.arom.with_travel.domain.accompanies.model.Country; import com.arom.with_travel.domain.accompanies.service.AccompanyApplyService; import com.arom.with_travel.domain.accompanies.service.AccompanyService; @@ -15,21 +13,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/accompanies") -@Tag(name = "동행 모집 글 CRUD 및 부가 기능") +@Tag(name = "동행 모집 글", description = "동행 모집 글 CRUD api") public class AccompanyController { private final AccompanyService accompanyService; @@ -70,5 +61,4 @@ public CursorSliceResponse showAccompaniesBrief( @RequestParam(defaultValue = "10") int size){ return accompanyService.showAccompaniesBrief(country, size, lastId); } - } diff --git a/src/main/java/com/arom/with_travel/domain/accompanies/dto/request/AccompanyPostRequest.java b/src/main/java/com/arom/with_travel/domain/accompanies/dto/request/AccompanyPostRequest.java index 58f05dc..166cb93 100644 --- a/src/main/java/com/arom/with_travel/domain/accompanies/dto/request/AccompanyPostRequest.java +++ b/src/main/java/com/arom/with_travel/domain/accompanies/dto/request/AccompanyPostRequest.java @@ -1,6 +1,7 @@ package com.arom.with_travel.domain.accompanies.dto.request; import com.arom.with_travel.domain.accompanies.model.*; +import com.arom.with_travel.domain.image.dto.ImageRequest; import com.arom.with_travel.global.annotation.Enum; import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.validation.constraints.FutureOrPresent; @@ -14,16 +15,17 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.List; @Getter @NoArgsConstructor public class AccompanyPostRequest { - @Enum(target = Continent.class, message = "대륙을 입력해주세요") + @Enum(target = Continent.class, message = "대륙을 입력해주세요(한글)") private Continent continent; - @Enum(target = Country.class, message = "국가를 입력해주세요") + @Enum(target = Country.class, message = "국가를 입력해주세요(한글)") private Country country; - @Enum(target = City.class, message = "도시를 입력해주세요") + @Enum(target = City.class, message = "도시를 입력해주세요(한글)") private City city; @Enum(target = AccompanyType.class, message = "동행 종류를 선택해주세요") private AccompanyType accompanyType; @@ -31,13 +33,13 @@ public class AccompanyPostRequest { @NotEmpty private String destination; @NotNull(message = "startDate는 필수입니다.") - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) // ex) 2025-08-01 + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) // ex) 2025-08-01 @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDate startDate; @NotNull(message = "startTime은 필수입니다.") - @DateTimeFormat(pattern = "HH:mm") // ex) 09:30 + @DateTimeFormat(pattern = "HH:mm") // ex) 09:30 @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") private LocalTime startTime; @@ -58,4 +60,5 @@ public class AccompanyPostRequest { @NotEmpty private String title; @NotEmpty private String description; @Min(1) private int maxParticipants; + private List images; } diff --git a/src/main/java/com/arom/with_travel/domain/accompanies/service/AccompanyService.java b/src/main/java/com/arom/with_travel/domain/accompanies/service/AccompanyService.java index ac089d4..cd9aac2 100644 --- a/src/main/java/com/arom/with_travel/domain/accompanies/service/AccompanyService.java +++ b/src/main/java/com/arom/with_travel/domain/accompanies/service/AccompanyService.java @@ -3,28 +3,25 @@ import com.arom.with_travel.domain.accompanies.dto.response.AccompanyBriefResponse; import com.arom.with_travel.domain.accompanies.dto.response.CursorSliceResponse; import com.arom.with_travel.domain.accompanies.model.Accompany; -import com.arom.with_travel.domain.accompanies.model.AccompanyApply; import com.arom.with_travel.domain.accompanies.dto.request.AccompanyPostRequest; import com.arom.with_travel.domain.accompanies.dto.response.AccompanyDetailsResponse; import com.arom.with_travel.domain.accompanies.model.Country; import com.arom.with_travel.domain.accompanies.repository.accompany.AccompanyRepository; -import com.arom.with_travel.domain.accompanies.repository.accompany.AccompanyApplyRepository; +import com.arom.with_travel.domain.image.Image; +import com.arom.with_travel.domain.image.ImageType; +import com.arom.with_travel.domain.image.repository.ImageRepository; import com.arom.with_travel.domain.likes.Likes; import com.arom.with_travel.domain.likes.repository.LikesRepository; import com.arom.with_travel.domain.member.Member; -import com.arom.with_travel.domain.member.dto.MemberProfileRequestDto; import com.arom.with_travel.domain.member.repository.MemberRepository; import com.arom.with_travel.global.exception.BaseException; import com.arom.with_travel.global.exception.error.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -36,12 +33,18 @@ public class AccompanyService { private final AccompanyRepository accompanyRepository; private final MemberRepository memberRepository; private final LikesRepository likesRepository; + private final ImageRepository imageRepository; @Transactional public String createAccompany(AccompanyPostRequest request, String oauthId) { Member member = loadMemberOrThrow(oauthId); Accompany accompany = Accompany.from(request); accompany.post(member); + List images = request.getImages() + .stream() + .map(imageReq -> Image.fromAccompany(imageReq.getImageName(), imageReq.getImageUrl(), accompany, ImageType.ACCOMPANY)) + .toList(); + imageRepository.saveAll(images); accompanyRepository.save(accompany); return "등록 되었습니다"; } @@ -71,7 +74,9 @@ public AccompanyDetailsResponse showAccompanyDetails(Long accompanyId){ @Transactional(readOnly = true) public CursorSliceResponse showAccompaniesBrief(Country country, int size, Long lastId){ Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Slice dtoSlice = accompanyRepository.findByCountry(country, lastId, pageable).map(AccompanyBriefResponse::from); + Slice dtoSlice = accompanyRepository + .findByCountry(country, lastId, pageable) + .map(AccompanyBriefResponse::from); return CursorSliceResponse.of(dtoSlice); } @@ -88,10 +93,6 @@ private Accompany loadAccompanyOrThrow(Long accompanyId){ private Optional loadLikes(Accompany accompany, Member member) { return likesRepository.findByAccompanyAndMember(accompany, member); } - - private long countLikes(Accompany accompany){ - return likesRepository.countByAccompanyId(accompany.getId()); - } } diff --git a/src/main/java/com/arom/with_travel/domain/accompanies/swagger/GetAccompaniesBrief.java b/src/main/java/com/arom/with_travel/domain/accompanies/swagger/GetAccompaniesBrief.java index 6dd5543..b530b21 100644 --- a/src/main/java/com/arom/with_travel/domain/accompanies/swagger/GetAccompaniesBrief.java +++ b/src/main/java/com/arom/with_travel/domain/accompanies/swagger/GetAccompaniesBrief.java @@ -20,7 +20,7 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Operation( - summary = "동행 목록 간단 조회", + summary = "동행 목록 간단 조회, 동행 첫 화면에 사용됨", description = """ 동행 목록 첫 페이지는 어느 국가를 기반으로 국가(country) 필터와 커서(lastId) 기반으로 @@ -31,7 +31,7 @@ name = "country", in = ParameterIn.QUERY, required = false, - description = "국가 코드 (예: KOREA, JAPAN)", + description = "국가 (예: 일본)", example = "KOREA" ) @Parameter( @@ -46,7 +46,7 @@ in = ParameterIn.QUERY, required = false, description = "한 번에 가져올 데이터 개수", - schema = @Schema(defaultValue = "10", minimum = "1", maximum = "50") + schema = @Schema(defaultValue = "10") ) @ApiResponses(value = { @ApiResponse( diff --git a/src/main/java/com/arom/with_travel/domain/image/Image.java b/src/main/java/com/arom/with_travel/domain/image/Image.java index 4c4b143..5313574 100644 --- a/src/main/java/com/arom/with_travel/domain/image/Image.java +++ b/src/main/java/com/arom/with_travel/domain/image/Image.java @@ -15,8 +15,6 @@ @Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) -@SQLDelete(sql = "UPDATE image SET is_deleted = true, deleted_at = now() where id = ?") -@SQLRestriction("is_deleted is FALSE") public class Image extends BaseEntity { @Id @@ -26,6 +24,8 @@ public class Image extends BaseEntity { @NotNull private String imageName; @NotNull private String imageUrl; + @Enumerated(EnumType.STRING) + private ImageType imageType; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "community_id") @@ -38,4 +38,40 @@ public class Image extends BaseEntity { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; + + private Image(String imageName, String imageUrl, Accompany accompany, ImageType imageType) { + this.imageName = imageName; + this.imageUrl = imageUrl; + this.accompany = accompany; + this.imageType = imageType; + this.accompany.getImages().add(this); + } + + private Image(String imageName, String imageUrl, Member member, ImageType imageType){ + this.imageName = imageName; + this.imageUrl = imageUrl; + this.member = member; + this.imageType = imageType; + this.member.uploadImage(this); + } + + private Image(String imageName, String imageUrl, Community community, ImageType imageType){ + this.imageName = imageName; + this.imageUrl = imageUrl; + this.imageType = imageType; + this.community = community; + this.community.getImages().add(this); + } + + public static Image fromAccompany(String imageName, String imageUrl, Accompany accompany, ImageType imageType) { + return new Image(imageName, imageUrl, accompany, imageType); + } + + public static Image fromMember(String imageName, String imageUrl, Member member, ImageType imageType) { + return new Image(imageName, imageUrl, member, imageType); + } + + public static Image fromCommunity(String imageName, String imageUrl, Community community) { + return new Image(imageName, imageUrl, community, ImageType.COMMUNITY); + } } diff --git a/src/main/java/com/arom/with_travel/domain/image/ImageType.java b/src/main/java/com/arom/with_travel/domain/image/ImageType.java new file mode 100644 index 0000000..7049e51 --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/image/ImageType.java @@ -0,0 +1,5 @@ +package com.arom.with_travel.domain.image; + +public enum ImageType { + ACCOMPANY, COMMUNITY, MEMBER_PROFILE +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/image/PostUploadImages.java b/src/main/java/com/arom/with_travel/domain/image/PostUploadImages.java new file mode 100644 index 0000000..9beccff --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/image/PostUploadImages.java @@ -0,0 +1,69 @@ +package com.arom.with_travel.domain.image; + +import com.arom.with_travel.domain.image.dto.UploadedImageResponse; +import com.arom.with_travel.global.exception.response.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.MediaType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = "이미지 업로드", + description = """ + Multipart/form-data 요청으로 여러 이미지를 S3에 업로드합니다. + 저장 디렉터리(dir) 파라미터는 S3 버킷 내의 하위 경로를 의미하며, + 정상 처리되면 업로드된 각 이미지의 URL과 원본 파일명을 담은 리스트를 반환합니다. + """) +@Parameters({ + @Parameter( + name = "files", + description = "업로드할 이미지 파일들 (복수 가능)", + in = ParameterIn.DEFAULT, + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + array = @ArraySchema(schema = @Schema(type = "string", format = "binary"))) + ), + @Parameter( + name = "dir", + description = """ + S3 저장 디렉터리 + 디렉토리 종류 : + accompany-img(동행글), + member-profile-img(프로필 이미지), + community-img(커뮤니티 글) + """, + in = ParameterIn.QUERY, + required = true, + schema = @Schema(type = "string") + ) +}) +@ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "업로드 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema(schema = @Schema(implementation = UploadedImageResponse.class))) + ), + @ApiResponse( + responseCode = "400", + description = "요청 형식 오류(파일 누락 등)", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class))) +}) +public @interface PostUploadImages { +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/image/controller/ImageController.java b/src/main/java/com/arom/with_travel/domain/image/controller/ImageController.java index 9228a7f..f05f6fc 100644 --- a/src/main/java/com/arom/with_travel/domain/image/controller/ImageController.java +++ b/src/main/java/com/arom/with_travel/domain/image/controller/ImageController.java @@ -1,8 +1,13 @@ package com.arom.with_travel.domain.image.controller; +import com.arom.with_travel.domain.image.PostUploadImages; +import com.arom.with_travel.domain.image.dto.UploadedImageResponse; +import com.arom.with_travel.global.oauth2.dto.CustomOAuth2User; import com.arom.with_travel.global.s3.service.S3Service; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -10,20 +15,22 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.List; @Slf4j @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1/image") +@RequestMapping("/api/v1/images") +@Tag(name = "이미지", description = "이미지 업로드 api") public class ImageController { private final S3Service s3Service; + @PostUploadImages @PostMapping("/upload") - public String upload( - @RequestParam("file") MultipartFile file, - @RequestParam("dir") String directory) throws IOException { - log.info("Uploading file {}", file.getOriginalFilename()); - return s3Service.uploadFile(file, directory); + public List uploadAccompanyImages(@AuthenticationPrincipal CustomOAuth2User user, + @RequestParam("files") List files, + @RequestParam("dir") String directory) throws IOException { + return s3Service.uploadFiles(files, directory); } -} +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/image/dto/ImageRequest.java b/src/main/java/com/arom/with_travel/domain/image/dto/ImageRequest.java new file mode 100644 index 0000000..539a1e4 --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/image/dto/ImageRequest.java @@ -0,0 +1,9 @@ +package com.arom.with_travel.domain.image.dto; + +import lombok.Getter; + +@Getter +public class ImageRequest { + private String imageName; + private String imageUrl; +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/image/dto/UploadedImageResponse.java b/src/main/java/com/arom/with_travel/domain/image/dto/UploadedImageResponse.java new file mode 100644 index 0000000..7ea302a --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/image/dto/UploadedImageResponse.java @@ -0,0 +1,11 @@ +package com.arom.with_travel.domain.image.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UploadedImageResponse { + private String originalFileName; + private String imageUrl; +} \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/image/repository/ImageRepository.java b/src/main/java/com/arom/with_travel/domain/image/repository/ImageRepository.java new file mode 100644 index 0000000..acab5d6 --- /dev/null +++ b/src/main/java/com/arom/with_travel/domain/image/repository/ImageRepository.java @@ -0,0 +1,7 @@ +package com.arom.with_travel.domain.image.repository; + +import com.arom.with_travel.domain.image.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/arom/with_travel/domain/member/Member.java b/src/main/java/com/arom/with_travel/domain/member/Member.java index 5dace18..641a4d7 100644 --- a/src/main/java/com/arom/with_travel/domain/member/Member.java +++ b/src/main/java/com/arom/with_travel/domain/member/Member.java @@ -8,7 +8,6 @@ import com.arom.with_travel.domain.community_reply.CommunityReply; import com.arom.with_travel.domain.image.Image; import com.arom.with_travel.domain.likes.Likes; -import com.arom.with_travel.domain.member.dto.MemberSignupRequestDto; import com.arom.with_travel.domain.shorts.Shorts; import com.arom.with_travel.domain.shorts_reply.ShortsReply; import com.arom.with_travel.domain.survey.Survey; @@ -25,7 +24,6 @@ import java.util.List; @Getter -@Setter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE member SET is_deleted = true, deleted_at = now() where id = ?") @@ -130,7 +128,7 @@ public void validateNotAlreadyAppliedTo(Accompany accompany) { boolean alreadyApplied = accompanyApplies.stream() .anyMatch(apply -> apply.getAccompany().equals(accompany)); if (alreadyApplied) { - throw BaseException.from(ErrorCode.TMP_ERROR); + throw BaseException.from(ErrorCode.ACCOMPANY_ALREADY_APPLIED); } } @@ -154,4 +152,8 @@ public void updateExtraInfo(String nickname, LocalDate birth, Gender gender) { public boolean needExtraInfo() { return nickname == null || birth == null || gender == null; } + + public void uploadImage(Image image){ + this.image = image; + } } \ No newline at end of file diff --git a/src/main/java/com/arom/with_travel/domain/member/controller/MemberProfileController.java b/src/main/java/com/arom/with_travel/domain/member/controller/MemberProfileController.java index ec5d9e6..f65ca2b 100644 --- a/src/main/java/com/arom/with_travel/domain/member/controller/MemberProfileController.java +++ b/src/main/java/com/arom/with_travel/domain/member/controller/MemberProfileController.java @@ -2,17 +2,18 @@ import com.arom.with_travel.domain.accompanies.dto.response.AccompanyDetailsResponse; import com.arom.with_travel.domain.accompanies.service.AccompanyService; +import com.arom.with_travel.domain.image.dto.ImageRequest; import com.arom.with_travel.domain.member.dto.MemberProfileRequestDto; import com.arom.with_travel.domain.member.dto.MemberProfileResponseDto; import com.arom.with_travel.domain.member.service.MemberProfileService; import com.arom.with_travel.domain.member.service.MemberService; import com.arom.with_travel.global.jwt.service.TokenProvider; +import com.arom.with_travel.global.oauth2.dto.CustomOAuth2User; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -49,5 +50,9 @@ public String getIntroduction(HttpServletRequest request){ return memberProfileService.getIntroduction(tokenProvider.getMemberLoginEmail(request)); } + @PostMapping("/profile-image") + public void uploadProfileImage(@AuthenticationPrincipal CustomOAuth2User user, @RequestBody ImageRequest request){ + memberProfileService.uploadMemberProfileImage(user, request.getImageName(), request.getImageUrl()); + } //note 동행후기, 작성한글, 좋아요 누른 글 => 추가하기 } diff --git a/src/main/java/com/arom/with_travel/domain/member/dto/MemberProfileResponseDto.java b/src/main/java/com/arom/with_travel/domain/member/dto/MemberProfileResponseDto.java index de3721f..7655a58 100644 --- a/src/main/java/com/arom/with_travel/domain/member/dto/MemberProfileResponseDto.java +++ b/src/main/java/com/arom/with_travel/domain/member/dto/MemberProfileResponseDto.java @@ -1,11 +1,8 @@ package com.arom.with_travel.domain.member.dto; -import com.arom.with_travel.domain.accompanies.model.Accompany; import com.arom.with_travel.domain.image.Image; import com.arom.with_travel.domain.member.Member; -import java.util.List; - public record MemberProfileResponseDto( // boolean isPrinciple, String memberNickname, diff --git a/src/main/java/com/arom/with_travel/domain/member/service/MemberProfileService.java b/src/main/java/com/arom/with_travel/domain/member/service/MemberProfileService.java index 6e4f583..b734211 100644 --- a/src/main/java/com/arom/with_travel/domain/member/service/MemberProfileService.java +++ b/src/main/java/com/arom/with_travel/domain/member/service/MemberProfileService.java @@ -6,6 +6,9 @@ import com.arom.with_travel.domain.accompanies.repository.accompany.AccompanyApplyRepository; import com.arom.with_travel.domain.accompanies.repository.accompany.AccompanyRepository; import com.arom.with_travel.domain.accompanies.repository.accompany.AccompanyApplyRepository; +import com.arom.with_travel.domain.image.Image; +import com.arom.with_travel.domain.image.ImageType; +import com.arom.with_travel.domain.image.repository.ImageRepository; import com.arom.with_travel.domain.likes.repository.LikesRepository; import com.arom.with_travel.domain.member.Member; @@ -14,6 +17,7 @@ import com.arom.with_travel.domain.member.repository.MemberRepository; import com.arom.with_travel.global.exception.BaseException; import com.arom.with_travel.global.exception.error.ErrorCode; +import com.arom.with_travel.global.oauth2.dto.CustomOAuth2User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; @@ -33,6 +37,7 @@ public class MemberProfileService { private final AccompanyRepository accompanyRepository; private final AccompanyApplyRepository applyRepository; private final LikesRepository likesRepository; + private final ImageRepository imageRepository; //내가 등록한 동행 정보들 public List myPostAccompany(String email){ @@ -77,6 +82,11 @@ public List myPastAccompany(String email){ .collect(Collectors.toList()); } + public void uploadMemberProfileImage(CustomOAuth2User user, String imageName, String imageUrl){ + Member member = loadMemberOrThrow(user.getEmail()); + Image image = Image.fromMember(imageName, imageUrl, member, ImageType.MEMBER_PROFILE); + imageRepository.save(image); + } private Member loadMemberOrThrow(String email) { return memberRepository.findByEmail(email).orElseThrow( diff --git a/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java b/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java index 54a1430..86b2e36 100644 --- a/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java +++ b/src/main/java/com/arom/with_travel/global/exception/error/ErrorCode.java @@ -9,8 +9,6 @@ public enum ErrorCode { TMP_ERROR("S3-0000", "파일 형식이 올바르지 않습니다.", ErrorDisplayType.MODAL), - //client error : 4xx - //member MEMBER_NOT_FOUND("MEM-0000", "해당 회원이 존재하지 않습니다.", ErrorDisplayType.POPUP), MEMBER_ALREADY_REGISTERED("MEM-0001", "이미 회원가입되어 있습니다.", ErrorDisplayType.POPUP), @@ -26,6 +24,7 @@ public enum ErrorCode { ACCOMPANY_ALREADY_LIKED("ACC-0003", "좋아요를 이미 눌렀습니다.", ErrorDisplayType.POPUP), ACCOMPANY_COMMENT_NOT_FOUND("ACC-0004", "해당 동행 댓글을 찾을 수 없습니다.", ErrorDisplayType.POPUP), ACCOMPANY_COMMENT_NO_PERMISSION_UPDATE("ACC-0005", "댓글을 수정할 수 없습니다.", ErrorDisplayType.POPUP), + // ACCOMPANY_ALREADY_CONFIRMED("ACC-0002", "참가 확정된 동행입니다.", ErrorDisplayType.POPUP) ACCOMPANY_LIKES_UNABLE_DECREASE("ACC-0006", "좋아요 수가 0보다 작습니다.", ErrorDisplayType.POPUP), @@ -34,8 +33,10 @@ public enum ErrorCode { INVALID_SURVEY_ANSWER("SVY-0001", "설문 답변이 비어있습니다.", ErrorDisplayType.POPUP), OVER_ANSWER_LIMIT("SVY-0002", "답변 개수가 초과되었습니다..", ErrorDisplayType.POPUP), INVALID_SURVEY_QUESTION("SVY-0003", "설문 질문이 비어있습니다.", ErrorDisplayType.POPUP), - ; + // image + INVALID_IMG_TYPE("IMG-0000", "지원하지 않는 이미지 형식입니다.", ErrorDisplayType.POPUP), + ; private final String code; private final String message; diff --git a/src/main/java/com/arom/with_travel/global/s3/properties/S3Properties.java b/src/main/java/com/arom/with_travel/global/s3/properties/S3Properties.java index 646bc67..5100a70 100644 --- a/src/main/java/com/arom/with_travel/global/s3/properties/S3Properties.java +++ b/src/main/java/com/arom/with_travel/global/s3/properties/S3Properties.java @@ -3,10 +3,20 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Set; + @Component public class S3Properties { public static String BUCKET_NAME; + public static final Set ALLOWED_IMAGE_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/bmp" + ); + public S3Properties( @Value("${cloud.aws.s3.bucket}") String bucketName ) { diff --git a/src/main/java/com/arom/with_travel/global/s3/service/S3Service.java b/src/main/java/com/arom/with_travel/global/s3/service/S3Service.java index 7c56483..2603158 100644 --- a/src/main/java/com/arom/with_travel/global/s3/service/S3Service.java +++ b/src/main/java/com/arom/with_travel/global/s3/service/S3Service.java @@ -1,11 +1,12 @@ package com.arom.with_travel.global.s3.service; +import com.arom.with_travel.domain.image.dto.UploadedImageResponse; import com.arom.with_travel.global.exception.BaseException; import com.arom.with_travel.global.exception.error.ErrorCode; import com.arom.with_travel.global.s3.properties.S3Properties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; @@ -13,26 +14,34 @@ import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; -@Service -@RequiredArgsConstructor +import static com.arom.with_travel.global.s3.properties.S3Properties.ALLOWED_IMAGE_TYPES; + @Slf4j +@Component +@RequiredArgsConstructor public class S3Service { private final S3Client s3Client; @Transactional - public String uploadFile(final MultipartFile file, String directory) throws IOException { - validateFileType(file); - String fileKey = getFileKey(directory); - PutObjectRequest request = PutObjectRequest.builder() - .bucket(S3Properties.BUCKET_NAME) - .key(fileKey) - .contentType(file.getContentType()) - .contentLength(file.getSize()) - .build(); - s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + public List uploadFiles(List files, String directory) throws IOException { + List uploadedImages = new ArrayList<>(); + for (MultipartFile file : files) { + validateFileType(file); + String fileKey = getFileKey(directory); + PutObjectRequest request = createUploadObject(file, fileKey); + upload(file, request); + String url = getUploadObjectUrl(fileKey); + uploadedImages.add(new UploadedImageResponse(file.getOriginalFilename(), url)); + } + return uploadedImages; + } + + private String getUploadObjectUrl(String fileKey) { return s3Client .utilities() .getUrl(builder -> builder @@ -41,11 +50,23 @@ public String uploadFile(final MultipartFile file, String directory) throws IOEx .toString(); } + private void upload(MultipartFile file, PutObjectRequest request) throws IOException { + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + } + + private PutObjectRequest createUploadObject(MultipartFile file, String fileKey) { + return PutObjectRequest.builder() + .bucket(S3Properties.BUCKET_NAME) + .key(fileKey) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + } - // TODO : 파일 형식 추후 지정 및 소프트 코딩 private void validateFileType(MultipartFile file) { - if (file.getContentType().equals("image/vnd.dwg")){ - throw BaseException.from(ErrorCode.TMP_ERROR); + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { + throw BaseException.from(ErrorCode.INVALID_IMG_TYPE); } } diff --git a/src/test/java/com/arom/with_travel/domain/member/service/MemberProfileTest.java b/src/test/java/com/arom/with_travel/domain/member/service/MemberProfileTest.java index af3502e..576988a 100644 --- a/src/test/java/com/arom/with_travel/domain/member/service/MemberProfileTest.java +++ b/src/test/java/com/arom/with_travel/domain/member/service/MemberProfileTest.java @@ -5,7 +5,6 @@ import com.arom.with_travel.domain.accompanies.repository.accompany.AccompanyApplyRepository; import com.arom.with_travel.domain.accompanies.repository.accompany.AccompanyRepository; import com.arom.with_travel.domain.accompany.model.AccompanyTest; -import com.arom.with_travel.domain.image.Image; import com.arom.with_travel.domain.likes.repository.LikesRepository; import com.arom.with_travel.domain.member.Member; import com.arom.with_travel.domain.member.model.MemberTest; @@ -22,7 +21,6 @@ import java.util.Optional; import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; -import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; @ActiveProfiles("test") From 4da28959ef797d3bab716c398783f2d370adbe38 Mon Sep 17 00:00:00 2001 From: LeeDongHoon Date: Fri, 1 Aug 2025 22:00:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=87=BC=EC=B8=A0=20api=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shorts/controller/ShortsController.java | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/main/java/com/arom/with_travel/domain/shorts/controller/ShortsController.java diff --git a/src/main/java/com/arom/with_travel/domain/shorts/controller/ShortsController.java b/src/main/java/com/arom/with_travel/domain/shorts/controller/ShortsController.java deleted file mode 100644 index bafa85f..0000000 --- a/src/main/java/com/arom/with_travel/domain/shorts/controller/ShortsController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.arom.with_travel.domain.shorts.controller; - -import com.arom.with_travel.global.s3.service.S3Service; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -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 java.io.IOException; - -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/shorts") -public class ShortsController { - - private final S3Service s3Service; - - @PostMapping("/upload") - public String upload( - @RequestParam("file") MultipartFile file, - @RequestParam("dir") String directory) throws IOException { - log.info("Uploading file {}", file.getOriginalFilename()); - return s3Service.uploadFile(file, directory); - } -}