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(),