diff --git a/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java b/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java index 807eb29d..801044f1 100644 --- a/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java @@ -1,12 +1,19 @@ package devkor.com.teamcback.domain.common.repository; import devkor.com.teamcback.domain.common.entity.File; +import devkor.com.teamcback.domain.review.repository.CustomFileRepository; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -public interface FileRepository extends JpaRepository { +public interface FileRepository extends JpaRepository, CustomFileRepository { List findAllByFileUuid(String fileUuid); + List findTop10AllByFileUuidOrderBySortNumAsc(String fileUuid); + + List findTop5AllByFileUuidOrderBySortNumAsc(String fileUuid); + + List findTop3AllByFileUuidOrderBySortNumAsc(String fileUuid); + File findByFileUuidAndSortNum(String fileUuid, Long sortNum); } diff --git a/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java b/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java index 53334460..f2734f7a 100644 --- a/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java +++ b/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java @@ -137,7 +137,7 @@ public void uploadThumb(File savedFile, MultipartFile file, Integer width, Integ InputStream inputStream = new ByteArrayInputStream(os.toByteArray()); // 저장 - String thumbSavedName = s3Util.uploadFile(inputStream, savedFile.getFileUuid(), filePath, file.getContentType()); + String thumbSavedName = s3Util.uploadFile(inputStream, savedFile.getFileOriginalName(), filePath, file.getContentType()); savedFile.setThumbSavedName(thumbSavedName); } @@ -167,6 +167,22 @@ public void deleteFile(String fileUuid) { fileRepository.deleteAll(savedFileList); } + public List getFiles(String fileUuid) { + return fileRepository.findAllByFileUuid(fileUuid); + } + + public List getTop10Files(String fileUuid) { + return fileRepository.findTop10AllByFileUuidOrderBySortNumAsc(fileUuid); + } + + public List getTop5Files(String fileUuid) { + return fileRepository.findTop5AllByFileUuidOrderBySortNumAsc(fileUuid); + } + + public List getTop3Files(String fileUuid) { + return fileRepository.findTop3AllByFileUuidOrderBySortNumAsc(fileUuid); + } + public List getOriginalFiles(String fileUuid) { List fileList = fileRepository.findAllByFileUuid(fileUuid).stream().map(File::getFileSavedName).toList(); diff --git a/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java b/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java index e53d939a..2f6a3c1c 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java +++ b/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java @@ -1,6 +1,5 @@ package devkor.com.teamcback.domain.place.controller; -import devkor.com.teamcback.domain.building.dto.response.SaveBuildingMainImageRes; import devkor.com.teamcback.domain.place.dto.request.CreatePlaceReq; import devkor.com.teamcback.domain.place.dto.request.ModifyPlaceReq; import devkor.com.teamcback.domain.place.dto.response.CreatePlaceRes; @@ -18,7 +17,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -92,7 +90,7 @@ public CommonResponse deletePlace( return CommonResponse.success(adminPlaceService.deletePlace(placeId)); } - @PostMapping(value = "/{placeId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) +// @PostMapping(value = "/{placeId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "장소 사진 저장", description = "장소 사진 저장(첫 사진이 대표 사진)") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), diff --git a/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java b/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java index 0d2e8c41..f54656f1 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java +++ b/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java @@ -65,14 +65,17 @@ public class Place extends BaseEntity { private Integer maskIndex; @Column(nullable = false) - private Integer starSum = 0; + private double starSum = 0; @Column(nullable = false) - private Integer starNum = 0; + private int starNum = 0; @Column private String contact; // 연락처 등 + @Column + private String foodType; + @ManyToOne @JoinColumn(name = "building_id") private Building building; diff --git a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceImage.java b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceImage.java deleted file mode 100644 index 42bddecf..00000000 --- a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceImage.java +++ /dev/null @@ -1,30 +0,0 @@ -package devkor.com.teamcback.domain.place.entity; - -import devkor.com.teamcback.domain.common.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Table(name = "tb_place_image") -@NoArgsConstructor -public class PlaceImage extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String image; // 추후 삭제 - - @ManyToOne - @JoinColumn(name = "place_id") - private Place place; -} diff --git a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceImageRepository.java b/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceImageRepository.java deleted file mode 100644 index 677dbe73..00000000 --- a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceImageRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package devkor.com.teamcback.domain.place.repository; - -import devkor.com.teamcback.domain.place.entity.Place; -import devkor.com.teamcback.domain.place.entity.PlaceImage; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PlaceImageRepository extends JpaRepository { - - List findAllByPlace(Place place); -} diff --git a/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java b/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java new file mode 100644 index 00000000..87f31e04 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java @@ -0,0 +1,131 @@ +package devkor.com.teamcback.domain.review.controller; + +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.review.dto.response.*; +import devkor.com.teamcback.domain.review.service.ReviewService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/reviews") +public class ReviewController { + + private final ReviewService reviewService; + + @Operation(summary = "리뷰 태그 종류 검색") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/tags") + public CommonResponse getReviewTagList() { + return CommonResponse.success(reviewService.getReviewTagList()); + } + + @Operation(summary = "리뷰가 있는 장소의 리뷰 목록 포함 상세 검색", + description = "식당, 카페") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/places/{placeId}") + public CommonResponse getReviewPlaceDetail( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId) { + + return CommonResponse.success(reviewService.getReviewPlaceDetail(placeId)); + } + + @Operation(summary = "리뷰가 있는 장소의 리뷰 목록 포함 상세 검색 - 무한스크롤로 리뷰 사진 추가 조회", + description = "식당, 카페") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/places/{placeId}/images") + public CommonResponse> getReviewPlaceDetailImages( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(name = "lastFileId", description = "마지막 조회한 사진 id") @RequestParam Long lastFileId) { + + return CommonResponse.success(reviewService.getReviewPlaceDetailImages(placeId, lastFileId)); + } + + // TODO: 리뷰 작성 시 포인트 부여 + @Operation(summary = "리뷰 작성", + description = "식당, 카페에 대한 리뷰를 작성") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping(value = "/places/{placeId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse createReview( + @Parameter(description = "사용자정보", required = true) @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(description = "리뷰 작성 내용", required = true) @Valid @ModelAttribute CreateReviewReq createReviewReq) { + + return CommonResponse.success(reviewService.createReview(userDetail.getUser().getUserId(), placeId, createReviewReq)); + } + + @Operation(summary = "리뷰 조회", + description = "식당, 카페에 대한 리뷰 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/{reviewId}") + public CommonResponse getReview( + @Parameter(name = "reviewId", description = "리뷰 ID") @PathVariable Long reviewId) { + return CommonResponse.success(reviewService.getReview(reviewId)); + } + + @Operation(summary = "리뷰 수정", + description = "식당, 카페에 대한 리뷰를 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping(value = "/{reviewId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse modifyReview( + @Parameter(description = "사용자정보", required = true) @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(name = "reviewId", description = "리뷰 ID") @PathVariable Long reviewId, + @Parameter(description = "리뷰 작성 내용", required = true) @Valid @ModelAttribute ModifyReviewReq modifyReviewReq) { + + return CommonResponse.success(reviewService.modifyReview(userDetail.getUser().getUserId(), reviewId, modifyReviewReq)); + } + + // TODO: 리뷰 삭제 시 포인트 제거 + @Operation(summary = "리뷰 삭제", + description = "식당, 카페에 대한 리뷰를 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping( "/{reviewId}") + public CommonResponse deleteReview( + @Parameter(description = "사용자정보", required = true) @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(name = "reviewId", description = "리뷰 ID") @PathVariable Long reviewId) { + return CommonResponse.success(reviewService.deleteReview(userDetail.getUser().getUserId(), reviewId)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/request/CreateReviewReq.java b/src/main/java/devkor/com/teamcback/domain/review/dto/request/CreateReviewReq.java new file mode 100644 index 00000000..1dbc376c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/request/CreateReviewReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "저장할 리뷰 정보") +@Getter +@Setter +public class CreateReviewReq { + + @Schema(description = "별점", example = "1.5") + @DecimalMin(value = "0.5", message = "별점을 입력해주세요.") + private double score; + + @Schema(description = "재방문 여부", example = "false") + private boolean isRevisit = false; + + @Schema(description = "리뷰 태그 리스트") + @Size(max = 5, message = "태그는 최대 5개까지 가능합니다.") + private List tagIds = new ArrayList<>(); + + @Schema(description = "한줄평", example = "맛있고 좋아요.") + private String comment; + + @Size(max = 3) + @Schema(description = "첨부 사진") + private List images = new ArrayList<>(); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/request/ModifyReviewReq.java b/src/main/java/devkor/com/teamcback/domain/review/dto/request/ModifyReviewReq.java new file mode 100644 index 00000000..77818cae --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/request/ModifyReviewReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "수정할 리뷰 정보") +@Getter +@Setter +public class ModifyReviewReq { + + @Schema(description = "별점", example = "1.5") + @DecimalMin(value = "0.5", message = "별점을 입력해주세요.") + private double score; + + @Schema(description = "재방문 여부", example = "false") + private boolean isRevisit = false; + + @Schema(description = "리뷰 태그 리스트") + @Size(max = 5, message = "태그는 최대 5개까지 가능합니다.") + private List tagIds = new ArrayList<>(); + + @Schema(description = "한줄평", example = "맛있고 좋아요.") + private String comment; + + @Size(max = 3) + @Schema(description = "첨부 사진") + private List images = new ArrayList<>(); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java new file mode 100644 index 00000000..14fc29e0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "리뷰 생성 응답 dto") +@Getter +public class CreateReviewRes { + private Long reviewId; + + public CreateReviewRes(Long id) { + this.reviewId = id; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java new file mode 100644 index 00000000..cad485c2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "리뷰 삭제 응답 dto") +@JsonIgnoreProperties +public class DeleteReviewRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewPlaceDetailRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewPlaceDetailRes.java new file mode 100644 index 00000000..692c918f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewPlaceDetailRes.java @@ -0,0 +1,60 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceImageRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceReviewTagRes; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.List; + +@Getter +public class GetReviewPlaceDetailRes { + + private Long placeId; + private Long nodeId; + private Integer maskIndex; + private PlaceType type; + private String name; + private String detail; + private String operatingTime; + private boolean availability; + private boolean plugAvailability; + private boolean isOperating; + private String description; + + @Schema(description = "별점 평균") + private String starAverage; + @Schema(description = "음식 카테고리", example = "한식") + private String foodType; + + @Schema(description = "장소 대표 사진 목록") + private List placeImages; + @Schema(description = "리뷰 태그 목록") + private List tagList; + @Schema(description = "리뷰 목록") + private List reviewList; + @Schema(description = "리뷰 사진 목록") + private List reviewImageList; + + public GetReviewPlaceDetailRes(Place place, List placeImageList, List reviewTagList, List reviewList, List reviewImageList) { + this.placeId = place.getId(); + this.nodeId = place.getNode().getId(); + this.maskIndex = place.getMaskIndex(); + this.type = place.getType(); + this.name = place.getName(); + this.detail = place.getDetail(); + this.operatingTime = place.getOperatingTime(); + this.availability = place.isAvailability(); + this.plugAvailability = place.isPlugAvailability(); + this.isOperating = place.isOperating(); + this.description = place.getDescription(); + this.starAverage = String.format("%.2f", ((double) place.getStarSum()) / place.getStarNum()); + this.foodType = place.getFoodType(); + this.placeImages = placeImageList; + this.tagList = reviewTagList; + this.reviewList = reviewList; + this.reviewImageList = reviewImageList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewRes.java new file mode 100644 index 00000000..10918d5a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewRes.java @@ -0,0 +1,53 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.review.entity.Review; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +@Getter +public class GetReviewRes { + @Schema(description = "리뷰 id") + private Long reviewId; + @Schema(description = "리뷰 사용자 fineUuid") + private String fileUuid; + @Schema(description = "별점") + private double score; + @Schema(description = "재방문 여부") + private boolean isRevisit; + @Schema(description = "한줄평") + private String comment; + @Schema(description = "리뷰 장소 id") + private Long placeId; + @Schema(description = "리뷰 사용자 id") + private Long userId; + @Schema(description = "리뷰 작성일") + private String createdAt; + @Schema(description = "리뷰 태그 목록") + private List tagList; + @Schema(description = "리뷰 사진 목록") + private List reviewImageList; + + public GetReviewRes(Review review, List tagList, List reviewImageRes) { + this.userId = review.getUser() != null ? review.getUser().getUserId() : null; + this.reviewId = review.getId(); + this.placeId = review.getPlace().getId(); + this.score = review.getScore(); + this.fileUuid = review.getFileUuid(); + this.isRevisit = review.isRevisit(); + this.comment = review.getComment(); + + // 작성일 변환 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy.MM.dd(E)", Locale.KOREAN); + this.createdAt = review.getCreatedAt() != null ? review.getCreatedAt().format(formatter): ""; + + // 리뷰 태그 + this.tagList = tagList; + + // 리뷰 사진 + this.reviewImageList = reviewImageRes; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagListRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagListRes.java new file mode 100644 index 00000000..048c086f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagListRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +public class GetReviewTagListRes { + private Map> reviewTags; + + public GetReviewTagListRes(Map> reviewTags) { + this.reviewTags = reviewTags; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagRes.java new file mode 100644 index 00000000..906d8a44 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.review.entity.ReviewTag; +import lombok.Getter; + +@Getter +public class GetReviewTagRes { + private Long reviewTagId; + private String tag; + + public GetReviewTagRes(ReviewTag reviewTag) { + this.reviewTagId = reviewTag.getId(); + this.tag = reviewTag.getTag(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java new file mode 100644 index 00000000..fdd545cb --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "리뷰 수정 응답 dto") +@Getter +public class ModifyReviewRes { + private Long reviewId; + + public ModifyReviewRes(Long id) { + this.reviewId = id; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java new file mode 100644 index 00000000..b9f71187 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.review.entity.Review; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +@Getter +public class SearchPlaceReviewRes { + + @Schema(description = "리뷰 사용자 id") + private Long userId; + + @Schema(description = "리뷰 id") + private Long reviewId; + + @Schema(description = "재방문 여부") + private boolean isRevisit; + + @Schema(description = "한줄평") + private String comment; + + @Schema(description = "리뷰 작성 시기") + private String createdAt; + + @Schema(description = "리뷰별 사진 목록") + private List reviewImageRes; + + public SearchPlaceReviewRes(Review review, List reviewImageRes) { + this.userId = review.getUser() != null ? review.getUser().getUserId() : null; + this.reviewId = review.getId(); + this.isRevisit = review.isRevisit(); + this.comment = review.getComment(); + + // 작성일 변환 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy.MM.dd(E)", Locale.KOREAN); + this.createdAt = review.getCreatedAt() != null ? review.getCreatedAt().format(formatter): ""; + + // 리뷰별 사진 + this.reviewImageRes = reviewImageRes; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchReviewImageRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchReviewImageRes.java new file mode 100644 index 00000000..dd06776b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchReviewImageRes.java @@ -0,0 +1,18 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SearchReviewImageRes { + private Long imageId; + private String image; + + public SearchReviewImageRes(Long imageId, String image) { + this.imageId = imageId; + this.image = image; + } +} + + diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/PlaceReviewTagMap.java b/src/main/java/devkor/com/teamcback/domain/review/entity/PlaceReviewTagMap.java new file mode 100644 index 00000000..e94ddc17 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/PlaceReviewTagMap.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.review.entity; + +import devkor.com.teamcback.domain.place.entity.Place; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Table(name = "tb_place_review_tag_map") +@NoArgsConstructor +public class PlaceReviewTagMap { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "place_id") + private Place place; + + @ManyToOne + @JoinColumn(name = "review_tag_id") + private ReviewTag reviewTag; + + @Setter + @Column(nullable = false) + private int num = 0; + + public PlaceReviewTagMap(Place place, ReviewTag tag) { + this.place = place; + this.reviewTag = tag; + this.num = 1; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java b/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java new file mode 100644 index 00000000..b626c2f2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java @@ -0,0 +1,65 @@ +package devkor.com.teamcback.domain.review.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.*; +import jakarta.validation.Valid; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Getter +@Table(name = "tb_review") +@NoArgsConstructor +public class Review extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String fileUuid; // 3장까지 업로드 가능 + + @Column(nullable = false) + private double score = 0; + + @Column(nullable = false) + private boolean isRevisit; // 재방문 여부 + + @Column(nullable = false, length = 500) + private String comment = ""; // 500자까지 작성 가능 + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "place_id") + private Place place; // 식당, 카페 등의 장소 + + @Setter + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) + private List reviewTagMaps = new ArrayList<>(); // 선택한 후기 태그 + + public Review(CreateReviewReq createReviewReq, User user, Place place) { + this.fileUuid = UUID.randomUUID().toString(); + this.score = createReviewReq.getScore(); + this.isRevisit = createReviewReq.isRevisit(); + this.comment = createReviewReq.getComment(); + this.user = user; + this.place = place; + } + + public void modify(@Valid ModifyReviewReq modifyReviewReq) { + this.score = modifyReviewReq.getScore(); + this.isRevisit = modifyReviewReq.isRevisit(); + this.comment = modifyReviewReq.getComment(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTag.java b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTag.java new file mode 100644 index 00000000..55ea1d5a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTag.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.review.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "tb_review_tag") +@NoArgsConstructor +public class ReviewTag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TagType type; + + @Column(nullable = false) + private String tag; +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTagMap.java b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTagMap.java new file mode 100644 index 00000000..87e87af1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTagMap.java @@ -0,0 +1,29 @@ +package devkor.com.teamcback.domain.review.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "tb_review_tag_map") +@NoArgsConstructor +public class ReviewTagMap { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 하나의 리뷰에 태그 최대 5개 + @ManyToOne + @JoinColumn(name = "review_id") + private Review review; + + @ManyToOne + @JoinColumn(name = "review_tag_id") + private ReviewTag reviewTag; + + public ReviewTagMap(Review savedReview, ReviewTag tag) { + this.review = savedReview; + this.reviewTag = tag; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/TagType.java b/src/main/java/devkor/com/teamcback/domain/review/entity/TagType.java new file mode 100644 index 00000000..0da4cb27 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/TagType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.review.entity; + +public enum TagType { + TASTE, MOOD; +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepository.java new file mode 100644 index 00000000..8d670bcd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepository.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.common.entity.File; + +import java.util.List; + +public interface CustomFileRepository { + List getReviewFilesByPlaceWithPage(Long placeId, Long lastFileId, int size); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepositoryImpl.java b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepositoryImpl.java new file mode 100644 index 00000000..c4706ecb --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepositoryImpl.java @@ -0,0 +1,37 @@ +package devkor.com.teamcback.domain.review.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import devkor.com.teamcback.domain.common.entity.File; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static devkor.com.teamcback.domain.common.entity.QFile.file; +import static devkor.com.teamcback.domain.review.entity.QReview.review; + +@Repository +@RequiredArgsConstructor +public class CustomFileRepositoryImpl implements CustomFileRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List getReviewFilesByPlaceWithPage(Long placeId, Long lastFileId, int size) { + return jpaQueryFactory + .select(file) + .from(file) + .join(review).on(file.fileUuid.eq(review.fileUuid)) + .where( + review.place.id.eq(placeId), + file.id.gt(lastFileId) + ) + .orderBy( + review.createdAt.desc(), + file.sortNum.asc() + ) + .limit(size) + .fetch(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/PlaceReviewTagMapRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/PlaceReviewTagMapRepository.java new file mode 100644 index 00000000..9a8f8741 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/PlaceReviewTagMapRepository.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.review.entity.PlaceReviewTagMap; +import devkor.com.teamcback.domain.review.entity.ReviewTag; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PlaceReviewTagMapRepository extends JpaRepository { + List findByPlaceOrderByNumDesc(Place place); + + PlaceReviewTagMap findByPlaceAndReviewTag(Place place, ReviewTag reviewTag); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java new file mode 100644 index 00000000..cdb794a8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.review.entity.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + + List findAllByPlaceOrderByCreatedAtDesc(Place place); + +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagMapRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagMapRepository.java new file mode 100644 index 00000000..8cfdbfa2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagMapRepository.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.review.entity.ReviewTagMap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewTagMapRepository extends JpaRepository { +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagRepository.java new file mode 100644 index 00000000..216d6a01 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagRepository.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.review.entity.ReviewTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewTagRepository extends JpaRepository { +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java new file mode 100644 index 00000000..3a17e1d0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java @@ -0,0 +1,335 @@ +package devkor.com.teamcback.domain.review.service; + +import devkor.com.teamcback.domain.common.entity.File; +import devkor.com.teamcback.domain.common.repository.FileRepository; +import devkor.com.teamcback.domain.common.util.FileUtil; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.review.dto.response.*; +import devkor.com.teamcback.domain.review.entity.*; +import devkor.com.teamcback.domain.review.repository.PlaceReviewTagMapRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.review.repository.ReviewTagMapRepository; +import devkor.com.teamcback.domain.review.repository.ReviewTagRepository; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceImageRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceReviewTagRes; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import devkor.com.teamcback.infra.s3.FilePath; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final PlaceRepository placeRepository; + private final ReviewRepository reviewRepository; + private final ReviewTagRepository reviewTagRepository; + private final ReviewTagMapRepository reviewTagMapRepository; + private final PlaceReviewTagMapRepository placeReviewTagMapRepository; + private final FileRepository fileRepository; + private final UserRepository userRepository; + private final FileUtil fileUtil; + + /** + * 리뷰 태그 종류 검색 + */ + public GetReviewTagListRes getReviewTagList() { + Map> reviewTagMap = new HashMap<>(); + + // 태그 종류 + for(TagType tagType : TagType.values()) { + reviewTagMap.put(tagType.name(), new ArrayList<>()); + } + + // 태그 종류별 내용 + List reviewTagList = reviewTagRepository.findAll(); + for(ReviewTag reviewTag : reviewTagList) { + reviewTagMap.get(reviewTag.getType().name()).add(new GetReviewTagRes(reviewTag)); + } + + return new GetReviewTagListRes(reviewTagMap); + } + + /** + * 리뷰 기능있는 장소 상세 조회 + */ + @Transactional(readOnly = true) + public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { + // 장소 검색 + Place place = findPlaceById(placeId); + + // 식당, 카페만 조회 가능하도록 제한 + if(place.getType() != PlaceType.CAFETERIA && place.getType() != PlaceType.CAFE) { + throw new GlobalException(ResultCode.NOT_SUPPORTED_PLACE_TYPE); + } + + // 장소 대표 사진 조회 (사진 5장 제한 - 원본 사진) + List placeImageList = new ArrayList<>(); + if(place.getFileUuid() != null) { + placeImageList = fileUtil.getTop5Files(place.getFileUuid()).stream().map(file -> new SearchPlaceImageRes(file.getId(), file.getFileSavedName())).toList(); + } + + // 장소 리뷰 태그 조회 + List reviewTagList = findPlaceReviewTagList(place); + + // 리뷰 최신순 조회 + List reviewList = reviewRepository.findAllByPlaceOrderByCreatedAtDesc(place); + + // 리뷰별 이미지 조회(썸네일) + List reviewImageList = new ArrayList<>(); + + // 리뷰별 이미지 조회 + for(Review review : reviewList) { + // 리뷰별 이미지 + List reviewFiles = fileUtil.getFiles(review.getFileUuid()); + + // 썸네일 이미지 추출 + List imageResList = reviewFiles.stream().map(file -> new SearchReviewImageRes(file.getId(), file.getThumbSavedName())).toList(); + reviewImageList.add(new SearchPlaceReviewRes(review, imageResList)); + } + + // 리뷰 전체 이미지 조회(원본 - 10장까지) + List totalReviewImageList = fileRepository.getReviewFilesByPlaceWithPage(placeId, 0L, 10).stream().map(file -> new SearchReviewImageRes(file.getId(), file.getFileSavedName())).toList(); + + return new GetReviewPlaceDetailRes(place, placeImageList, reviewTagList, reviewImageList, totalReviewImageList); + } + + /** + * 리뷰 기능있는 장소 상세 조회 - 리뷰 사진 추가 조회 + */ + @Transactional(readOnly = true) + public List getReviewPlaceDetailImages(Long placeId, Long lastFileId) { + // 장소 검색 + Place place = findPlaceById(placeId); + + // 식당, 카페만 조회 가능하도록 제한 + if(place.getType() != PlaceType.CAFETERIA && place.getType() != PlaceType.CAFE) { + throw new GlobalException(ResultCode.NOT_SUPPORTED_PLACE_TYPE); + } + + // 리뷰 전체 이미지 조회(원본 - 10장까지) + return fileRepository.getReviewFilesByPlaceWithPage(placeId, lastFileId, 10).stream().map(file -> new SearchReviewImageRes(file.getId(), file.getFileSavedName())).toList(); + + } + + /** + * 리뷰 작성 + */ + @Transactional + public CreateReviewRes createReview(Long userId, Long placeId, CreateReviewReq createReviewReq) { + // 사용자 검색 + User user = findUserById(userId); + + // 장소 검색 + Place place = findPlaceById(placeId); + + // 리뷰 저장 + Review savedReview = reviewRepository.save(new Review(createReviewReq, user, place)); + + // 사진 저장 + fileUtil.upload(createReviewReq.getImages(), savedReview.getFileUuid(), null, FilePath.REVIEW); + + // 리뷰 태그 저장 + saveReviewTagMap(savedReview, createReviewReq.getTagIds()); + + // 장소 별점 추가 + place.setStarNum(place.getStarNum() + 1); + place.setStarSum(place.getStarSum() + createReviewReq.getScore()); + + return new CreateReviewRes(savedReview.getId()); + } + + /** + * 리뷰 조회 + */ + @Transactional(readOnly = true) + public GetReviewRes getReview(Long reviewId) { + // 리뷰 검색 + Review review = findReviewById(reviewId); + + // 리뷰 태그 조회 + List tagResList = new ArrayList<>(); + for(ReviewTagMap reviewTagMap : review.getReviewTagMaps()) { + tagResList.add(new GetReviewTagRes(reviewTagMap.getReviewTag())); + } + + // 리뷰 이미지 조회 + List imageResList = fileUtil.getFiles(review.getFileUuid()).stream().map(file -> new SearchReviewImageRes(file.getId(), file.getFileSavedName())).toList(); + + return new GetReviewRes(review, tagResList, imageResList); + } + + /** + * 리뷰 수정 + */ + @Transactional + public ModifyReviewRes modifyReview(Long userId, Long reviewId, @Valid ModifyReviewReq modifyReviewReq) { + // 사용자 검색 + User user = findUserById(userId); + + // 리뷰 검색 + Review review = findReviewById(reviewId); + + // 권한 검사 (자신이 작성한 리뷰만 수정 가능) + validateUser(user, review); + + // 사진 삭제 + fileUtil.deleteFile(review.getFileUuid()); + + // 사진 저장 + fileUtil.upload(modifyReviewReq.getImages(), review.getFileUuid(), null, FilePath.REVIEW); + + // 리뷰 수정 + review.modify(modifyReviewReq); + + // 기존 태그 삭제 + deleteReviewTagMap(review); + + // 태그 저장 + saveReviewTagMap(review, modifyReviewReq.getTagIds()); + + // 장소 평점 수정 + review.getPlace().setStarSum(review.getPlace().getStarSum() - review.getScore() + modifyReviewReq.getScore()); + + return new ModifyReviewRes(review.getId()); + } + + /** + * 리뷰 삭제 + */ + @Transactional + public DeleteReviewRes deleteReview(Long userId, Long reviewId) { + // 사용자 검색 + User user = findUserById(userId); + + // 리뷰 검색 + Review review = findReviewById(reviewId); + + // 권한 검사 (자신이 작성한 리뷰만 삭제 가능) + validateUser(user, review); + + // 리뷰 태그 삭제 + deleteReviewTagMap(review); + + // 사진 삭제 + fileUtil.deleteFile(review.getFileUuid()); + + // 장소 평정 수정 + review.getPlace().setStarNum(review.getPlace().getStarNum() - 1); + review.getPlace().setStarSum(review.getPlace().getStarSum() - review.getScore()); + + // 리뷰 삭제 + reviewRepository.delete(review); + + return new DeleteReviewRes(); + } + + /** + * 장소 검색 + */ + private Place findPlaceById(Long id) { + return placeRepository.findById(id).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_PLACE)); + } + + /** + * 사용자 검색 + */ + private User findUserById(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); + } + + /** + * 리뷰 검색 + */ + private Review findReviewById(Long reviewId) { + return reviewRepository.findById(reviewId).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_REVIEW)); + } + + /** + * 장소별 리뷰 태그 조회 + */ + private List findPlaceReviewTagList(Place place) { + List reviewTagMaps = placeReviewTagMapRepository.findByPlaceOrderByNumDesc(place); + + List tagResList = new ArrayList<>(); + for(PlaceReviewTagMap reviewTagMap : reviewTagMaps) { + tagResList.add(new SearchPlaceReviewTagRes(reviewTagMap.getReviewTag().getId(), reviewTagMap.getReviewTag().getTag(), reviewTagMap.getNum())); + } + + return tagResList; + } + + /** + * 리뷰 권한 검사 + */ + private void validateUser(User user, Review review) { + // 권한 검사 (자신이 작성한 리뷰만 수정 가능) + if(!user.getUserId().equals(review.getUser().getUserId())) { + throw new GlobalException(ResultCode.UNAUTHORIZED); + } + } + + /** + * 리뷰 태그 저장 + */ + private void saveReviewTagMap(Review review, List reviewTagIds) { + + // 태그 저장 + List reviewTagMaps = new ArrayList<>(); + for(Long tagId : reviewTagIds) { + ReviewTag tag = reviewTagRepository.findById(tagId).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_REVIEW_TAG)); + + // 리뷰 태그 저장 + ReviewTagMap reviewTagMap = reviewTagMapRepository.save(new ReviewTagMap(review, tag)); + reviewTagMaps.add(reviewTagMap); + + // 장소 태그 저장 + PlaceReviewTagMap placeReviewTagMap = placeReviewTagMapRepository.findByPlaceAndReviewTag(review.getPlace(), tag); + + if(placeReviewTagMap != null) { + placeReviewTagMap.setNum(placeReviewTagMap.getNum() + 1); + } + else { + placeReviewTagMapRepository.save(new PlaceReviewTagMap(review.getPlace(), tag)); + } + } + + // 리뷰에 태그 목록 저장 + review.setReviewTagMaps(reviewTagMaps); + } + + /** + * 기존 리뷰 태그 삭제 + */ + private void deleteReviewTagMap(Review review) { + // 기존 태그 삭제 + List reviewTagMaps = review.getReviewTagMaps(); + review.setReviewTagMaps(null); + + for(ReviewTagMap reviewTagMap : reviewTagMaps) { + // 장소 태그 수정 + PlaceReviewTagMap placeReviewTagMap = placeReviewTagMapRepository.findByPlaceAndReviewTag(review.getPlace(), reviewTagMap.getReviewTag()); + placeReviewTagMap.setNum(placeReviewTagMap.getNum() - 1); + if(placeReviewTagMap.getNum() <= 0) { + placeReviewTagMapRepository.delete(placeReviewTagMap); + } + + // 리뷰 태그 삭제 + reviewTagMapRepository.delete(reviewTagMap); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java index 5c875df7..d5a539fd 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java @@ -4,6 +4,10 @@ import devkor.com.teamcback.domain.place.entity.PlaceType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; @Schema(description = "편의시설 정보") @Getter @@ -44,6 +48,11 @@ public class SearchFacilityRes { private String description; @Schema(description = "편의시설 별점", example = "NaN 또는 3.6666666666666665") private String starAverage; + @Schema(description = "식당인 경우 음식 카테고리", example = "한식") + private String foodType; + @Setter + @Schema(description = "식당인 경우 리뷰 태그 목록") + private List tagList; public SearchFacilityRes(Place place, String imageUrl) { this.id = place.getId(); @@ -64,5 +73,6 @@ public SearchFacilityRes(Place place, String imageUrl) { this.floor = place.getFloor().intValue(); this.description = place.getDescription(); this.starAverage = String.format("%.2f", ((double) place.getStarSum()) / place.getStarNum()); + this.foodType = place.getFoodType(); } } diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java index 1ad73458..8c8ad4f2 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java @@ -1,6 +1,5 @@ package devkor.com.teamcback.domain.search.dto.response; -import devkor.com.teamcback.domain.place.entity.PlaceImage; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,11 +9,6 @@ public class SearchPlaceImageRes { private Long imageId; private String image; - public SearchPlaceImageRes(PlaceImage placeImage) { - this.imageId = placeImage.getId(); - this.image = placeImage.getImage(); - } - public SearchPlaceImageRes(Long imageId, String image) { this.imageId = imageId; this.image = image; diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java index 54e7228f..f2c851df 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java @@ -6,6 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; @Schema(description = "건물 및 강의실 조회 결과") @Getter @@ -55,6 +58,11 @@ public class SearchPlaceRes { private String description; @Schema(description = "편의시설 별점", example = "NaN 또는 3.6666666666666665") private String starAverage; + @Schema(description = "식당인 경우 음식 카테고리", example = "한식") + private String foodType; + @Setter + @Schema(description = "식당인 경우 리뷰 태그 목록") + private List tagList; public SearchPlaceRes(Place place, String imageUrl) { this.id = place.getId(); @@ -79,5 +87,6 @@ public SearchPlaceRes(Place place, String imageUrl) { this.placeType = place.getType(); this.description = place.getDescription(); this.starAverage = String.format("%.2f", ((double) place.getStarSum()) / place.getStarNum()); + this.foodType = place.getFoodType(); } } diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceReviewTagRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceReviewTagRes.java new file mode 100644 index 00000000..4a064ff0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceReviewTagRes.java @@ -0,0 +1,25 @@ +package devkor.com.teamcback.domain.search.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "장소의 리뷰 태그 목록") +@Getter +@NoArgsConstructor +public class SearchPlaceReviewTagRes { + @Schema(description = "리뷰 태그 id", example = "1") + private Long id; + + @Schema(description = "태그 내용", example = "맛있어요") + private String tag; + + @Schema(description = "태그 개수", example = "5") + private int num; + + public SearchPlaceReviewTagRes(Long id, String tag, int num) { + this.id = id; + this.tag = tag; + this.num = num; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java index 13bbc735..8ec72be7 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java @@ -7,8 +7,11 @@ import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceType; import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; import java.util.regex.Pattern; import lombok.Getter; +import lombok.Setter; @Getter public class SearchRoomDetailRes { @@ -33,6 +36,9 @@ public class SearchRoomDetailRes { private String description; private String starAverage; private String nextPlaceTime; + private String foodType; + @Setter + private List tagList; public SearchRoomDetailRes(Place place, String imageUrl) { this.id = place.getId(); @@ -62,5 +68,6 @@ else if(place.isOperating()) { // 운영 중이면 종료 시간 else { // 운영 종료인 경우 여는 시간 this.nextPlaceTime = place.getOperatingTime().substring(0, 5); } + this.foodType = place.getFoodType(); } } diff --git a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java index e4287db0..00f1c16d 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java +++ b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java @@ -12,9 +12,11 @@ import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceNickname; import devkor.com.teamcback.domain.place.entity.PlaceType; -import devkor.com.teamcback.domain.place.repository.PlaceImageRepository; import devkor.com.teamcback.domain.place.repository.PlaceNicknameRepository; import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.entity.PlaceReviewTagMap; +import devkor.com.teamcback.domain.review.repository.PlaceReviewTagMapRepository; +import devkor.com.teamcback.domain.review.repository.ReviewTagRepository; import devkor.com.teamcback.domain.routes.repository.NodeRepository; import devkor.com.teamcback.domain.search.dto.request.SaveSearchLogReq; import devkor.com.teamcback.domain.search.dto.response.*; @@ -54,8 +56,9 @@ public class SearchService { private final UserRepository userRepository; private final CategoryRepository categoryRepository; private final BookmarkRepository bookmarkRepository; - private final PlaceImageRepository placeImageRepository; private final NodeRepository nodeRepository; + private final PlaceReviewTagMapRepository placeReviewTagMapRepository; + private final ReviewTagRepository reviewTagRepository; private final LogUtil logUtil; private final FileUtil fileUtil; @@ -196,7 +199,15 @@ public SearchBuildingFacilityListRes searchBuildingFacilityByType(Long buildingI if(place.getFileUuid() != null) { imageUrl = fileUtil.getThumbnail(place.getFileUuid()); } - map.get(place.getFloor()).add(new SearchFacilityRes(place, imageUrl)); + + SearchFacilityRes facilityRes = new SearchFacilityRes(place, imageUrl); + + // 리뷰 내용 추가 + if(place.getType() == PlaceType.CAFE || place.getType() == PlaceType.CAFETERIA) { + facilityRes.setTagList(findPlaceReviewTagList(place)); + } + + map.get(place.getFloor()).add(facilityRes); } res.setFacilities(map); @@ -236,7 +247,14 @@ public SearchFloorInfoRes searchPlaceByBuildingFloor(Long buildingId, int floor) imageUrl = fileUtil.getThumbnail(place.getFileUuid()); } - placeResList.add(new SearchRoomDetailRes(place, imageUrl)); + SearchRoomDetailRes detailRes = new SearchRoomDetailRes(place, imageUrl); + + // 리뷰 내용 추가 + if(place.getType() == PlaceType.CAFE || place.getType() == PlaceType.CAFETERIA) { + detailRes.setTagList(findPlaceReviewTagList(place)); + } + + placeResList.add(detailRes); } List nodeList = nodeRepository.findAllByBuildingAndFloorAndTypeIn(building, floor, List.of(ENTRANCE, STAIR, ELEVATOR)) @@ -276,7 +294,14 @@ public SearchFacilityListRes searchFacilitiesWithType(PlaceType placeType) { imageUrl = fileUtil.getThumbnail(place.getFileUuid()); } - placeResList.add(new SearchPlaceRes(place, imageUrl)); + SearchPlaceRes placeRes = new SearchPlaceRes(place, imageUrl); + + // 리뷰 내용 추가 + if(place.getType() == PlaceType.CAFE || place.getType() == PlaceType.CAFETERIA) { + placeRes.setTagList(findPlaceReviewTagList(place)); + } + + placeResList.add(placeRes); } logUtil.logClick(null, null, null, placeType.toString()); return new SearchFacilityListRes(placeResList); @@ -345,7 +370,7 @@ public SearchBuildingDetailRes searchBuildingDetail(Long userId, Long buildingId // 건물 대표 이미지 확인 String imageUrl = null; if(building.getFileUuid() != null) { - imageUrl = fileUtil.getThumbnail(building.getFileUuid()); + imageUrl = fileUtil.getOriginalFile(building.getFileUuid()); } logUtil.logClick(building.getName(), null, null, null); return new SearchBuildingDetailRes(res, containPlaceTypes, building, imageUrl, bookmarked); @@ -395,16 +420,12 @@ public SearchPlaceDetailRes searchPlaceDetail(Long userId, Long placeId) { Place place = findPlace(placeId); // 장소 사진 - // TODO: 나중에 수정 String imageUrl = null; - List placeImageList; + List placeImageList = new ArrayList<>(); if(place.getFileUuid() != null) { - placeImageList = fileUtil.getThumbnailFiles(place.getFileUuid()).stream().map(image -> new SearchPlaceImageRes(0L, image)).toList(); + placeImageList = fileUtil.getFiles(place.getFileUuid()).stream().map(file -> new SearchPlaceImageRes(file.getId(), file.getFileSavedName())).toList(); imageUrl = placeImageList.isEmpty() ? null : placeImageList.get(0).getImage(); } - else { - placeImageList = new ArrayList<>();placeImageRepository.findAllByPlace(place).stream().map(SearchPlaceImageRes::new).toList(); - } // 즐겨찾기 if(bookmarkRepository.existsByLocationIdAndLocationTypeAndCategoryBookmarkList_CategoryIn(place.getId(), LocationType.PLACE, categories)) { @@ -705,4 +726,15 @@ private Building findBuilding(Long buildingId) { private Place findPlace(Long placeId) { return placeRepository.findById(placeId).orElseThrow(() -> new GlobalException(NOT_FOUND_PLACE)); } + + private List findPlaceReviewTagList(Place place) { + List reviewTagMaps = placeReviewTagMapRepository.findByPlaceOrderByNumDesc(place); + + List tagResList = new ArrayList<>(); + for(PlaceReviewTagMap reviewTagMap : reviewTagMaps) { + tagResList.add(new SearchPlaceReviewTagRes(reviewTagMap.getReviewTag().getId(), reviewTagMap.getReviewTag().getTag(), reviewTagMap.getNum())); + } + + return tagResList; + } } diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index 504d7e7b..2e79b076 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -24,6 +24,7 @@ public enum ResultCode { FORBIDDEN(HttpStatus.FORBIDDEN, 1013, "권한이 없는 사용자입니다."), DB_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 1014, "DB 데이터 문제가 발생했습니다."), UNSUPPORTED_REQUEST(HttpStatus.NOT_FOUND, 1015, "존재하지 않는 요청입니다."), + EXCEEDED_MAXIMUM_FILE_COUNT(HttpStatus.BAD_REQUEST, 1016, "최대 파일 개수를 초과했습니다."), // 사용자 2000번대 NOT_FOUND_USER(HttpStatus.NOT_FOUND, 2000, "사용자를 찾을 수 없습니다."), @@ -91,7 +92,11 @@ public enum ResultCode { EXISTING_PLACE_FOR_DEVICE(HttpStatus.CONFLICT, 13004, "중복되는 device placeId입니다."), // 강의 14000번대 - NOT_FOUND_COURSE(HttpStatus.NOT_FOUND, 14000, "강의를 찾을 수 없습니다.") + NOT_FOUND_COURSE(HttpStatus.NOT_FOUND, 14000, "강의를 찾을 수 없습니다."), + + // 리뷰 15000번대 + NOT_FOUND_REVIEW_TAG(HttpStatus.NOT_FOUND, 15000, "리뷰 태그를 찾을 수 없습니다."), + NOT_FOUND_REVIEW(HttpStatus.NOT_FOUND, 15001, "리뷰를 찾을 수 없습니다.") ; private final HttpStatus status; diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index c9b1a62c..ab990384 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -90,6 +90,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 .requestMatchers("/api/categories/**").authenticated() .requestMatchers("/api/bookmarks/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 .anyRequest().permitAll() ).exceptionHandling(ex -> ex .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 diff --git a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java index 18d7c776..4c75cbbe 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java @@ -10,7 +10,8 @@ public enum FilePath { // 파일 경로를 나타내는 상수를 정의 BUILDING("building/"), PLACE("place/"), BUILDING_IMAGE("buildingImage/"), - SUGGESTION("suggestion/"); + SUGGESTION("suggestion/"), + REVIEW("review/"); private final String path; // 경로를 저장하는 final 필드 } diff --git a/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java b/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java index 64280467..a2dd731f 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java @@ -50,12 +50,8 @@ private static ObjectMetadata setObjectMetadata(InputStream inputStream, String return metadata; } - // TODO: 추후 정리 - public String uploadFile(MultipartFile file, FilePath filePath) { - return uploadFile(file, filePath, UUID.randomUUID().toString()); - } - public String uploadFile(MultipartFile multipartFile, FilePath filePath, String fileUuid) { + public String uploadFile(MultipartFile multipartFile, FilePath filePath) { // 업로드할 파일이 존재하지 않거나 비어있으면 null 반환 if (multipartFile == null || multipartFile.isEmpty()) { return null; @@ -73,8 +69,13 @@ public String uploadFile(MultipartFile multipartFile, FilePath filePath, String throw new GlobalException(MAXIMUM_UPLOAD_FILE_SIZE); } + String originalName = multipartFile.getOriginalFilename(); + if(originalName == null || originalName.isEmpty()) { + originalName = UUID.randomUUID().toString(); + } + // 파일명을 UTF-8로 디코딩 - String fileName = URLDecoder.decode(fileUuid, StandardCharsets.UTF_8); + String fileName = URLDecoder.decode(originalName, StandardCharsets.UTF_8); // 업로드할 파일의 메타데이터 생성 ObjectMetadata metadata = setObjectMetadata(multipartFile); @@ -91,9 +92,9 @@ public String uploadFile(MultipartFile multipartFile, FilePath filePath, String return getFileUrl(fileName, filePath); } - public String uploadFile(InputStream inputStream, String fileUuid, FilePath filePath, String contentType) { + public String uploadFile(InputStream inputStream, String originalFileName, FilePath filePath, String contentType) { // 파일명을 UTF-8로 디코딩 - String fileName = URLDecoder.decode(fileUuid, StandardCharsets.UTF_8); + String fileName = URLDecoder.decode(originalFileName, StandardCharsets.UTF_8); try { // 업로드할 파일의 메타데이터 생성 @@ -139,7 +140,6 @@ public void deleteFile(String fileUrl, String filePath) { amazonS3Client.deleteObject(bucketName, filePath + fileName); } - // TODO: 추후 정리 public boolean exists(String fileUrl, FilePath filePath) { // 주어진 파일 URL로부터 파일명을 추출 String fileName = getFileNameFromFileUrl(fileUrl, filePath); @@ -166,7 +166,6 @@ public boolean exists(String fileUrl, String filePath) { return true; } - // TODO: 추후 정리 private String getFileUrl(String fileName, FilePath filePath) { // AWS S3 클라이언트를 사용하여 주어진 버킷, 파일 경로 및 파일명에 해당하는 파일의 URL을 얻어옴 return amazonS3Client.getUrl(bucketName, filePath.getPath() + fileName).toString(); @@ -177,7 +176,6 @@ private String getFileUrl(String fileName, String filePath) { return amazonS3Client.getUrl(bucketName, filePath+ fileName).toString(); } - // TODO: 추후 정리 private String getFileNameFromFileUrl(String fileUrl, FilePath filePath) { // 파일 URL에서 파일 경로 다음의 문자열부터 파일명의 끝까지 추출하여 반환 return fileUrl.substring(fileUrl.lastIndexOf(filePath.getPath()) + filePath.getPath().length()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 625c517d..0922602f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,7 +26,7 @@ spring: servlet: multipart: - location: /app/temp + location: /tmp max-file-size: 5MB max-request-size: 50MB