Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/main/java/goorm/ddok/cafe/controller/CafeReviewController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package goorm.ddok.cafe.controller;

import goorm.ddok.cafe.dto.request.CafeReviewCreateRequest;
import goorm.ddok.cafe.dto.response.CafeReviewCreateResponse;
import goorm.ddok.cafe.service.CafeReviewService;
import goorm.ddok.global.exception.ErrorCode;
import goorm.ddok.global.exception.GlobalException;
import goorm.ddok.global.response.ApiResponseDto;
import goorm.ddok.global.security.auth.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.*;
import io.swagger.v3.oas.annotations.responses.*;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/map/cafes")
@RequiredArgsConstructor
@Tag(name = "Cafe Reviews", description = "카페 후기 API")
public class CafeReviewController {

private final CafeReviewService cafeReviewService;

@Operation(
summary = "카페 후기 작성",
security = @SecurityRequirement(name = "Authorization")
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ApiResponseDto.class))),
@ApiResponse(responseCode = "400", description = "검증 실패",
content = @Content(schema = @Schema(implementation = ApiResponseDto.class))),
@ApiResponse(responseCode = "401", description = "인증 필요",
content = @Content(schema = @Schema(implementation = ApiResponseDto.class))),
@ApiResponse(responseCode = "404", description = "카페 없음",
content = @Content(schema = @Schema(implementation = ApiResponseDto.class)))
})
@PostMapping("/{cafeId}/reviews")
public ResponseEntity<ApiResponseDto<CafeReviewCreateResponse>> create(
@PathVariable Long cafeId,
@Validated @RequestBody CafeReviewCreateRequest request,
@AuthenticationPrincipal CustomUserDetails me
) {
if (me == null || me.getUser() == null) {
throw new GlobalException(ErrorCode.UNAUTHORIZED);
}

CafeReviewCreateResponse data =
cafeReviewService.createReview(cafeId, me.getUser().getId(), request);

return ResponseEntity.ok(ApiResponseDto.of(200, "카페 후기 작성에 성공하였습니다.", data));
}
}
2 changes: 1 addition & 1 deletion src/main/java/goorm/ddok/cafe/domain/CafeReview.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
@Table(name = "cafe_review",
uniqueConstraints = {
@UniqueConstraint(name = "uk_cafe_review_cafe_user_status",
columnNames = {"cafe_id","user_id","status"})
columnNames = {"cafe_id","user_id","status","created_at"})
},
indexes = {
@Index(name = "idx_cafe_review_cafe_id", columnList = "cafe_id"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package goorm.ddok.cafe.dto.request;

import jakarta.validation.constraints.*;
import lombok.*;

import java.math.BigDecimal;
import java.util.List;

@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class CafeReviewCreateRequest {

@NotNull(message = "평점은 필수입니다.")
// 소수 1자리 제한은 서비스에서 추가 검증(스케일 체크)도 수행
private BigDecimal rating;

@Builder.Default
private List<@NotBlank String> cafeReviewTag = List.of();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package goorm.ddok.cafe.dto.response;

import lombok.*;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;

@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class CafeReviewCreateResponse {
private Long userId;
private Long reviewId;

private String title; // 카페명
private String nickname; // 작성자 닉네임
private String profileImageUrl; // 작성자 프로필

private BigDecimal rating;
private Boolean isMine;

private List<String> cafeReviewTag;

private Instant createdAt;
private Instant updatedAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import goorm.ddok.cafe.domain.Cafe;
import goorm.ddok.cafe.domain.CafeReview;
import goorm.ddok.cafe.domain.CafeReviewStatus;
import goorm.ddok.member.domain.User;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
Expand Down Expand Up @@ -46,4 +47,6 @@ select coalesce(avg(r.rating), 0)
and r.deletedAt is null
""")
Page<CafeReview> findPageActiveByCafeId(@Param("cafeId") Long cafeId, Pageable pageable);

Optional<CafeReview> findFirstByCafe_IdAndUser_IdAndStatus(Long cafeId, Long userId, CafeReviewStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import goorm.ddok.cafe.domain.CafeReviewTag;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

public interface CafeReviewTagRepository extends JpaRepository<CafeReviewTag, Long> {
Optional<CafeReviewTag> findByName(String name);
boolean existsByName(String name);

List<CafeReviewTag> findByNameIn(Collection<String> names);
}
105 changes: 105 additions & 0 deletions src/main/java/goorm/ddok/cafe/service/CafeReviewService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package goorm.ddok.cafe.service;

import goorm.ddok.cafe.domain.*;
import goorm.ddok.cafe.dto.request.CafeReviewCreateRequest;
import goorm.ddok.cafe.dto.response.CafeReviewCreateResponse;
import goorm.ddok.cafe.repository.*;
import goorm.ddok.global.exception.ErrorCode;
import goorm.ddok.global.exception.GlobalException;
import goorm.ddok.member.domain.User;
import goorm.ddok.member.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class CafeReviewService {

private final CafeRepository cafeRepository;
private final CafeReviewRepository cafeReviewRepository;
private final CafeReviewTagRepository cafeReviewTagRepository;
private final CafeReviewTagMapRepository cafeReviewTagMapRepository;
private final UserRepository userRepository;

@Transactional
public CafeReviewCreateResponse createReview(Long cafeId, Long meUserId, CafeReviewCreateRequest req) {
// 1) 카페/유저 검증
Cafe cafe = cafeRepository.findById(cafeId)
.orElseThrow(() -> new GlobalException(ErrorCode.CAFE_NOT_FOUND));

User me = userRepository.findById(meUserId)
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));

// 2) 평점 검증 (0.1 단위 소수1자리, 0.0 < rating ≤ 5.0)
BigDecimal rating = normalizeAndValidateRating(req.getRating());

// 4) 리뷰 생성
CafeReview review = cafeReviewRepository.save(
CafeReview.builder()
.cafe(cafe)
.user(me)
.rating(rating)
.status(CafeReviewStatus.ACTIVE)
.build()
);
// 요청 태그 정제
List<String> names = Optional.ofNullable(req.getCafeReviewTag()).orElseGet(List::of).stream()
.map(s -> s == null ? "" : s.trim())
.filter(s -> !s.isBlank())
.distinct()
.limit(20) // 안전 가드
.toList();

// DB에서 실제 태그 조회
List<CafeReviewTag> existingTags = cafeReviewTagRepository.findByNameIn(names);

// === 요청된 태그 수와 DB 조회된 태그 수가 다르면 => 잘못된 태그 포함 ===
if (existingTags.size() != names.size()) {
throw new GlobalException(ErrorCode.INVALID_REVIEW_TAG);
}

// 정상적인 경우에만 저장 진행
for (CafeReviewTag tag : existingTags) {
cafeReviewTagMapRepository.save(
CafeReviewTagMap.builder()
.review(review)
.tag(tag)
.build()
);
}

// 6) 응답 DTO
return CafeReviewCreateResponse.builder()
.userId(me.getId())
.reviewId(review.getId())
.title(cafe.getName())
.nickname(me.getNickname())
.profileImageUrl(me.getProfileImageUrl())
.rating(review.getRating())
.isMine(true)
.cafeReviewTag(names)
.createdAt(review.getCreatedAt())
.updatedAt(review.getUpdatedAt())
.build();
}

/** rating 검증/정규화: null/범위/스케일 체크 */
private BigDecimal normalizeAndValidateRating(BigDecimal raw) {
if (raw == null) throw new GlobalException(ErrorCode.INVALID_RATING);
double r = raw.doubleValue();
if (r <= 0.0 || r > 5.0) throw new GlobalException(ErrorCode.INVALID_RATING);
// 0.5 단위 체크: r * 2 가 정수인지
double times2 = r * 2.0;
if (Math.abs(times2 - Math.round(times2)) > 1e-9) {
throw new GlobalException(ErrorCode.INVALID_RATING);
}
return BigDecimal.valueOf(r).setScale(1, RoundingMode.HALF_UP);
}
}
2 changes: 2 additions & 0 deletions src/main/java/goorm/ddok/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public enum ErrorCode {
ALREADY_EXPELLED(HttpStatus.BAD_REQUEST, "이미 추방되었거나 탈퇴한 팀원입니다."),
LEADER_CANNOT_BE_EXPELLED(HttpStatus.BAD_REQUEST, "리더는 추방할 수 없습니다."),
LEADER_CANNOT_WITHDRAW(HttpStatus.BAD_REQUEST, "리더는 하차할 수 없습니다."),
INVALID_REVIEW_TAG(HttpStatus.BAD_REQUEST, "잘못된 리뷰 태그가 포함되어 있습니다."),
INVALID_RATING(HttpStatus.BAD_REQUEST,"평점이 올바르지 않습니다."),
CHAT_MESSAGE_INVALID(HttpStatus.BAD_REQUEST, "메세지 내용이 없습니다."),
RECRUITMENT_CLOSED(HttpStatus.BAD_REQUEST, "현재 모집 중이 아닙니다."),

Expand Down
Loading