#294 (리뷰/메모): - MemoService.upsert: 동시성 INSERT 시 DuplicateKeyException 폴백 → UPDATE - ReviewService.toggleFavorite: 동시성 INSERT 시 DuplicateKeyException ignored (토글 ON) - ReviewController: rating(0~5) Bean validation 헬퍼, body.rating null/비숫자 → 400 - ReviewMapper.xml getAvgRating: NVL로 0건 시에도 0.0 보장 #295 (채널): - ChannelController.create: typed DataIntegrityViolationException으로 유니크 충돌 감지 (제약명 문자열 매칭 폐기) - ChannelController.create: channel_id/channel_name null/빈값 → 400 - ChannelService.deactivate: "UC..." 형식 검증으로 명시적 분기 (이전 폴백 방식의 의도 모호함 해결) - ChannelMapper.xml findByChannelId: description/tags/sort_order까지 SELECT #290 (식당 CRUD): - RestaurantController: @PreDestroy로 virtual thread executor shutdown - RestaurantController: 캐시 역직렬화 실패를 silent ignore → log.warn + cache.del 자동 evict - RestaurantController: setTablingUrl/setCatchtableUrl URL 스킴 화이트리스트 검증 - CacheService: 단일 키 del() 메서드 추가 후속 분리: - #333 (#290 DTO 화이트리스트 + DDG 대체) - #334 (#295 cache.flush 세분화 + scan 비동기) - #335 (#294 테스트) Refs: #290 #294 #295
111 lines
4.4 KiB
Java
111 lines
4.4 KiB
Java
package com.tasteby.controller;
|
|
|
|
import com.tasteby.domain.Restaurant;
|
|
import com.tasteby.domain.Review;
|
|
import com.tasteby.security.AuthUtil;
|
|
import com.tasteby.service.ReviewService;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.web.bind.annotation.*;
|
|
import org.springframework.web.server.ResponseStatusException;
|
|
|
|
import java.time.LocalDate;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
@RestController
|
|
@RequestMapping("/api")
|
|
public class ReviewController {
|
|
|
|
private final ReviewService reviewService;
|
|
|
|
public ReviewController(ReviewService reviewService) {
|
|
this.reviewService = reviewService;
|
|
}
|
|
|
|
@GetMapping("/restaurants/{restaurantId}/reviews")
|
|
public Map<String, Object> listRestaurantReviews(
|
|
@PathVariable String restaurantId,
|
|
@RequestParam(defaultValue = "20") int limit,
|
|
@RequestParam(defaultValue = "0") int offset) {
|
|
var reviews = reviewService.findByRestaurant(restaurantId, limit, offset);
|
|
var stats = reviewService.getAvgRating(restaurantId);
|
|
return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"),
|
|
"review_count", stats.get("review_count"));
|
|
}
|
|
|
|
@PostMapping("/restaurants/{restaurantId}/reviews")
|
|
@ResponseStatus(HttpStatus.CREATED)
|
|
public Review createReview(
|
|
@PathVariable String restaurantId,
|
|
@RequestBody Map<String, Object> body) {
|
|
String userId = AuthUtil.getUserId();
|
|
double rating = requireRating(body.get("rating"));
|
|
String text = (String) body.get("review_text");
|
|
LocalDate visitedAt = body.get("visited_at") != null
|
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
|
return reviewService.create(userId, restaurantId, rating, text, visitedAt);
|
|
}
|
|
|
|
@PutMapping("/reviews/{reviewId}")
|
|
public Map<String, Object> updateReview(
|
|
@PathVariable String reviewId,
|
|
@RequestBody Map<String, Object> body) {
|
|
String userId = AuthUtil.getUserId();
|
|
Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
|
|
String text = (String) body.get("review_text");
|
|
LocalDate visitedAt = body.get("visited_at") != null
|
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
|
if (!reviewService.update(reviewId, userId, rating, text, visitedAt)) {
|
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
|
}
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@DeleteMapping("/reviews/{reviewId}")
|
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
public void deleteReview(@PathVariable String reviewId) {
|
|
String userId = AuthUtil.getUserId();
|
|
if (!reviewService.delete(reviewId, userId)) {
|
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
|
}
|
|
}
|
|
|
|
@GetMapping("/users/me/reviews")
|
|
public List<Review> myReviews(
|
|
@RequestParam(defaultValue = "20") int limit,
|
|
@RequestParam(defaultValue = "0") int offset) {
|
|
return reviewService.findByUser(AuthUtil.getUserId(), limit, offset);
|
|
}
|
|
|
|
// Favorites
|
|
@GetMapping("/restaurants/{restaurantId}/favorite")
|
|
public Map<String, Object> favoriteStatus(@PathVariable String restaurantId) {
|
|
return Map.of("favorited", reviewService.isFavorited(AuthUtil.getUserId(), restaurantId));
|
|
}
|
|
|
|
@PostMapping("/restaurants/{restaurantId}/favorite")
|
|
public Map<String, Object> toggleFavorite(@PathVariable String restaurantId) {
|
|
boolean result = reviewService.toggleFavorite(AuthUtil.getUserId(), restaurantId);
|
|
return Map.of("favorited", result);
|
|
}
|
|
|
|
@GetMapping("/users/me/favorites")
|
|
public List<Restaurant> myFavorites() {
|
|
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
|
}
|
|
|
|
/**
|
|
* #294 — rating 검증: null/비숫자/범위 외 입력은 400.
|
|
*/
|
|
private static double requireRating(Object raw) {
|
|
if (!(raw instanceof Number n)) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 숫자여야 합니다");
|
|
}
|
|
double v = n.doubleValue();
|
|
if (v < 0.0 || v > 5.0 || Double.isNaN(v)) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 0.0 ~ 5.0 범위여야 합니다");
|
|
}
|
|
return v;
|
|
}
|
|
}
|