diff --git a/build.gradle b/build.gradle index 07c10e8..c606871 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ sonar { property 'sonar.language', 'java' property 'sonar.sourceEncoding', 'UTF-8' property("sonar.test.inclusions", "**/*Test.java") - property "sonar.exclusions", "**/test/**, **/*Application*.java, **/dto/**, **/entity/**, **/*Exception*.java, **/*RepositoryImpl.java, **/global/**, **/resources/**, **/*Dao*.java, **/dev/**" + property "sonar.exclusions", "**/test/**, **/*Application*.java, **/dto/**, **/entity/**, **/*Exception*.java, **/*RepositoryImpl.java, **/global/**, **/resources/**, **/*Dao*.java, **/dev/**, **/admin/**, **/Image*.java" property "sonar.java.coveragePlugin", "jacoco" property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml' } @@ -127,6 +127,8 @@ jacocoTestReport { "**/global/*", "**/*Dao*", "**/dev/**", + "**/admin/**", + "**/Image*" ]) })) } @@ -162,6 +164,8 @@ jacocoTestCoverageVerification { "**/global/*", "**/*Dao*", "**/dev/**", + "**/admin/**", + "**/Image*" ]) })) } diff --git a/src/main/java/everymeal/server/admin/controller/AdminController.java b/src/main/java/everymeal/server/admin/controller/AdminController.java new file mode 100644 index 0000000..948837a --- /dev/null +++ b/src/main/java/everymeal/server/admin/controller/AdminController.java @@ -0,0 +1,33 @@ +package everymeal.server.admin.controller; + + +import everymeal.server.admin.dto.AdminUserDto.DefaultProfileImageRes; +import everymeal.server.admin.service.AdminUserService; +import everymeal.server.global.dto.response.ApplicationResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@Tag(name = "Admin API", description = "어드민에서 관리되는 데이터 관련 API입니다.") +public class AdminController { + + private final AdminUserService adminUserService; + + @Operation( + summary = "유저의 기본 프로필 이미지 정보를 반환합니다.", + description = + "유저의 기본 프로필 이미지 정보를 반환합니다.
" + + "피그마에 노출되는 순서대로 반환합니다.
" + + "기본 이미지를 선택 시, imgUrl에 imageKey를 넣어주세요.
") + @GetMapping("/default-profile-images") + public ApplicationResponse> getDefaultProfileImages() { + return ApplicationResponse.ok(adminUserService.getDefaultProfileImages()); + } +} diff --git a/src/main/java/everymeal/server/admin/dto/AdminUserDto.java b/src/main/java/everymeal/server/admin/dto/AdminUserDto.java new file mode 100644 index 0000000..e41ab74 --- /dev/null +++ b/src/main/java/everymeal/server/admin/dto/AdminUserDto.java @@ -0,0 +1,22 @@ +package everymeal.server.admin.dto; + +import static everymeal.server.global.util.aws.S3Util.getImgUrl; + +import everymeal.server.admin.entity.UserDefaultProfileImage; +import io.swagger.v3.oas.annotations.media.Schema; + +public class AdminUserDto { + + @Schema(description = "어드민에서 관리되는 유저의 기본 프로필 이미지 정보를 담은 응답 DTO입니다.") + public record DefaultProfileImageRes( + @Schema(description = "유저의 기본 프로필 이미지의 idx입니다.") Long idx, + @Schema(description = "유저의 기본 프로필 이미지의 URL입니다.") String profileImageUrl, + @Schema(description = "유저의 기본 프로필 이미지의 imageKey입니다.") String imageKey) { + public static DefaultProfileImageRes of(UserDefaultProfileImage entity) { + return new DefaultProfileImageRes( + entity.getIdx(), + getImgUrl(entity.getProfileImgUrl()), + entity.getProfileImgUrl()); + } + } +} diff --git a/src/main/java/everymeal/server/admin/entity/UserDefaultProfileImage.java b/src/main/java/everymeal/server/admin/entity/UserDefaultProfileImage.java new file mode 100644 index 0000000..3d7034d --- /dev/null +++ b/src/main/java/everymeal/server/admin/entity/UserDefaultProfileImage.java @@ -0,0 +1,29 @@ +package everymeal.server.admin.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +@Getter +@Table(catalog = "admin", name = "user_default_profile_image") +@Entity +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class UserDefaultProfileImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long idx; + + private String profileImgUrl; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/everymeal/server/admin/repository/UserDefaultProfileImageRepository.java b/src/main/java/everymeal/server/admin/repository/UserDefaultProfileImageRepository.java new file mode 100644 index 0000000..fa4ab8c --- /dev/null +++ b/src/main/java/everymeal/server/admin/repository/UserDefaultProfileImageRepository.java @@ -0,0 +1,8 @@ +package everymeal.server.admin.repository; + + +import everymeal.server.admin.entity.UserDefaultProfileImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserDefaultProfileImageRepository + extends JpaRepository {} diff --git a/src/main/java/everymeal/server/admin/service/AdminUserService.java b/src/main/java/everymeal/server/admin/service/AdminUserService.java new file mode 100644 index 0000000..8484cb1 --- /dev/null +++ b/src/main/java/everymeal/server/admin/service/AdminUserService.java @@ -0,0 +1,23 @@ +package everymeal.server.admin.service; + + +import everymeal.server.admin.dto.AdminUserDto.DefaultProfileImageRes; +import everymeal.server.admin.repository.UserDefaultProfileImageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private final UserDefaultProfileImageRepository userDefaultProfileImageRepository; + + public List getDefaultProfileImages() { + return userDefaultProfileImageRepository.findAll().stream() + .map(DefaultProfileImageRes::of) + .toList(); + } +} diff --git a/src/main/java/everymeal/server/global/util/aws/S3Util.java b/src/main/java/everymeal/server/global/util/aws/S3Util.java index 4a2b32f..9cd5a43 100644 --- a/src/main/java/everymeal/server/global/util/aws/S3Util.java +++ b/src/main/java/everymeal/server/global/util/aws/S3Util.java @@ -7,16 +7,19 @@ import com.amazonaws.regions.Regions; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.DeleteObjectRequest; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import java.io.File; import java.net.URL; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component +@Slf4j public class S3Util { public static AmazonS3 amazonS3; @@ -56,4 +59,14 @@ public static String getImgUrl(String fileName) { URL url = amazonS3.getUrl(bucket, runningName + File.separator + fileName); return url.toString(); } + + public void deleteImage(String fileUrl) { + try { + String fileKey = "dev/" + fileUrl; + amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileKey)); + } catch (Exception e) { + e.printStackTrace(); + log.error("S3 이미지 삭제 실패 fileUrl: {}", fileUrl); + } + } } diff --git a/src/main/java/everymeal/server/global/util/aws/controller/S3Controller.java b/src/main/java/everymeal/server/global/util/aws/controller/S3Controller.java index e342c2f..2dfd627 100644 --- a/src/main/java/everymeal/server/global/util/aws/controller/S3Controller.java +++ b/src/main/java/everymeal/server/global/util/aws/controller/S3Controller.java @@ -11,6 +11,7 @@ import java.net.URL; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -50,4 +51,23 @@ public ApplicationResponse getPresignedUrl( return ApplicationResponse.ok( S3GetResignedUrlRes.builder().imageKey(fileName).url(test.toString()).build()); } + + @Operation( + summary = "S3 이미지 삭제", + description = + """ + S3에 저장된 이미지를 삭제합니다. + 언제 사용하는 API인가요? + 1. 리뷰 작성 중 이미지를 업로드하고, 리뷰 작성을 취소할 때 + 2. 리뷰 수정 중 이미지를 업로드하고, 리뷰 수정을 취소할 때 + 3. 리뷰 삭제 시 이미지를 삭제할 때 + 4. 이미지를 잘못 업로드 했을 때 + 5. 유저 프로필 이미지를 변경할 때 ( 기본 이미지 제외 ) +""") + @DeleteMapping("/image") + public ApplicationResponse deleteImage( + @RequestParam(value = "fileName") String fileName) { + s3Util.deleteImage(fileName); + return ApplicationResponse.ok(); + } } diff --git a/src/main/java/everymeal/server/review/dto/request/ReviewCreateReq.java b/src/main/java/everymeal/server/review/dto/request/ReviewCreateReq.java index 1a5721b..08f88b6 100644 --- a/src/main/java/everymeal/server/review/dto/request/ReviewCreateReq.java +++ b/src/main/java/everymeal/server/review/dto/request/ReviewCreateReq.java @@ -5,6 +5,7 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.util.List; public record ReviewCreateReq( @@ -14,7 +15,7 @@ public record ReviewCreateReq( description = "학식에 대한 리뷰 평가 점수를 정수형 최소 1 ~ 최대 5점까지로 입력해주세요. 사진 리뷰인 경우 0으로 보내주세요.", defaultValue = "5") - @Nullable + @NotNull Integer grade, @Schema( description = "학식에 대한 리뷰 내용을 1글자 이상 입력해주세요. 사진 리뷰인 경우 null로 보내주세요.", diff --git a/src/main/java/everymeal/server/review/entity/Image.java b/src/main/java/everymeal/server/review/entity/Image.java index 3fdf73d..ae52493 100644 --- a/src/main/java/everymeal/server/review/entity/Image.java +++ b/src/main/java/everymeal/server/review/entity/Image.java @@ -35,4 +35,8 @@ public Image(String imageUrl, Review review) { this.isDeleted = false; this.review = review; } + + public void deleteImage() { + this.isDeleted = true; + } } diff --git a/src/main/java/everymeal/server/review/entity/Review.java b/src/main/java/everymeal/server/review/entity/Review.java index c0bbf49..c7e08a2 100644 --- a/src/main/java/everymeal/server/review/entity/Review.java +++ b/src/main/java/everymeal/server/review/entity/Review.java @@ -90,9 +90,8 @@ public Review( public void updateEntity(String content, int grade, List images, Boolean todayReview) { this.content = content; this.grade = grade; - if (images != null) { - this.images.clear(); - } + // 이미지 연관 리스트 지우고 새로운 이미지 리스트로 교체 + this.images.clear(); this.images = images; this.isTodayReview = todayReview; } diff --git a/src/main/java/everymeal/server/review/repository/ReviewRepositoryImpl.java b/src/main/java/everymeal/server/review/repository/ReviewRepositoryImpl.java index 80ba344..352b93e 100644 --- a/src/main/java/everymeal/server/review/repository/ReviewRepositoryImpl.java +++ b/src/main/java/everymeal/server/review/repository/ReviewRepositoryImpl.java @@ -34,10 +34,16 @@ public ReviewPagingVOWithCnt getReview(ReviewDto.ReviewQueryParam queryParam) { jpaQueryFactory .select(review) .from(review) - .leftJoin(review.images, image) - .leftJoin(review.reviewMarks, reviewMark) - .leftJoin(review.restaurant) - .on(restaurant.idx.eq(queryParam.restaurantIdx())) + .leftJoin(image) + .on(review.idx.eq(image.review.idx).and(image.isDeleted.eq(Boolean.FALSE))) + .leftJoin(reviewMark) + .on(review.idx.eq(reviewMark.review.idx)) + .innerJoin(restaurant) + .on( + review.restaurant + .idx + .eq(restaurant.idx) + .and(restaurant.idx.eq(queryParam.restaurantIdx()))) .where( gtReviewIdx(queryParam.cursorIdx()), isDeleted(), @@ -51,10 +57,22 @@ public ReviewPagingVOWithCnt getReview(ReviewDto.ReviewQueryParam queryParam) { jpaQueryFactory .select(review.idx.count()) .from(review) - .leftJoin(review.images, image) - .leftJoin(review.reviewMarks, reviewMark) - .leftJoin(review.restaurant) - .on(restaurant.idx.eq(queryParam.restaurantIdx())) + .leftJoin(image) + .on( + review.idx + .eq(image.review.idx) + .and(image.isDeleted.eq(Boolean.FALSE))) + .leftJoin(reviewMark) + .on(review.idx.eq(reviewMark.review.idx)) + .innerJoin(restaurant) + .on( + review.restaurant + .idx + .eq(restaurant.idx) + .and( + restaurant.idx.eq( + queryParam + .restaurantIdx()))) .where(isDeleted(), eqToday(queryParam.filter())) .fetchOne()) .intValue(); diff --git a/src/main/java/everymeal/server/review/service/ImageCommServiceImpl.java b/src/main/java/everymeal/server/review/service/ImageCommServiceImpl.java new file mode 100644 index 0000000..7549641 --- /dev/null +++ b/src/main/java/everymeal/server/review/service/ImageCommServiceImpl.java @@ -0,0 +1,20 @@ +package everymeal.server.review.service; + + +import everymeal.server.review.entity.Image; +import everymeal.server.review.repository.ImageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ImageCommServiceImpl { + + private final ImageRepository imageRepository; + + @Transactional + public void deleteImage(Image alreadyImg) { + imageRepository.delete(alreadyImg); + } +} diff --git a/src/main/java/everymeal/server/review/service/ReviewServiceImpl.java b/src/main/java/everymeal/server/review/service/ReviewServiceImpl.java index 6bc9a65..9f58e21 100644 --- a/src/main/java/everymeal/server/review/service/ReviewServiceImpl.java +++ b/src/main/java/everymeal/server/review/service/ReviewServiceImpl.java @@ -4,9 +4,11 @@ import static everymeal.server.global.exception.ExceptionList.REVIEW_MARK_NOT_FOUND; import static everymeal.server.global.exception.ExceptionList.REVIEW_UNAUTHORIZED; import static everymeal.server.global.exception.ExceptionList.STORE_NOT_FOUND; +import static everymeal.server.global.util.aws.S3Util.getImgUrl; import everymeal.server.global.exception.ApplicationException; import everymeal.server.global.util.TimeFormatUtil; +import everymeal.server.global.util.aws.S3Util; import everymeal.server.meal.entity.Restaurant; import everymeal.server.meal.service.RestaurantCommServiceImpl; import everymeal.server.review.dto.request.ReviewCreateReq; @@ -36,6 +38,8 @@ public class ReviewServiceImpl implements ReviewService { private final UserCommServiceImpl userCommServiceImpl; private final ReviewCommServiceImpl reviewCommServiceImpl; private final StoreRepository storeRepository; + private final S3Util s3Util; + private final ImageCommServiceImpl imageCommServiceImpl; @Override @Transactional @@ -80,6 +84,17 @@ public Long updateReview(ReviewCreateReq request, Long userIdx, Long reviewIdx) Review review = reviewCommServiceImpl.getReviewEntity(reviewIdx); // (2) 이미지 주소 <> 이미지 객체 치환 + List alreadyImageList = review.getImages(); + if (!alreadyImageList.isEmpty()) { // [1,2,3] <> [1,2,4] 3을 삭제 + List reqImgList = request.imageList(); + for (Image alreadyImg : alreadyImageList) { + if (!reqImgList.contains(alreadyImg.getImageUrl())) { + s3Util.deleteImage(alreadyImg.getImageUrl()); + imageCommServiceImpl.deleteImage(alreadyImg); + } + } + } + List imageList = getImageFromString(request.imageList()); User user = userCommServiceImpl.getUserEntity(userIdx); if (review.getUser() != user) { @@ -101,6 +116,7 @@ public Boolean deleteReview(Long userIdx, Long reviewIdx) { Review review = reviewCommServiceImpl.getReviewEntity(reviewIdx, user); // (2) 기존 데이터 삭제 review.getRestaurant().removeGrade(review.getGrade()); + review.getImages().forEach(Image::deleteImage); review.deleteEntity(); reviewCommServiceImpl @@ -125,7 +141,7 @@ public ReviewGetRes getReviewWithNoOffSetPaging(ReviewDto.ReviewQueryParam query vo.getIdx(), vo.getRestaurant().getName(), vo.getUser().getNickname(), - vo.getUser().getProfileImgUrl(), + getImgUrl(vo.getUser().getProfileImgUrl()), vo.isTodayReview(), vo.getGrade(), vo.getContent(),