diff --git a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java index 933790b..eec618a 100644 --- a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java +++ b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java @@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; import com.tasteby.service.ChannelService; import com.tasteby.service.YouTubeService; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -52,15 +53,20 @@ public class ChannelController { String channelId = body.get("channel_id"); String channelName = body.get("channel_name"); String titleFilter = body.get("title_filter"); + // #295 — body 필수값 가드 (NOT NULL 컬럼에 빈 값 들어가 500 나는 것 방지) + if (channelId == null || channelId.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_id는 필수입니다"); + } + if (channelName == null || channelName.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_name은 필수입니다"); + } try { String id = channelService.create(channelId, channelName, titleFilter); cache.flush(); return Map.of("id", id, "channel_id", channelId); - } catch (Exception e) { - if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists"); - } - throw e; + } catch (DataIntegrityViolationException e) { + // #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고). + throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists"); } } diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java index 5eedee9..7640f25 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; import com.tasteby.service.GeocodingService; import com.tasteby.service.RestaurantService; +import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -47,6 +48,12 @@ public class RestaurantController { this.objectMapper = objectMapper; } + // #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지. + @PreDestroy + public void shutdownExecutor() { + executor.shutdown(); + } + @GetMapping public List list( @RequestParam(defaultValue = "100") int limit, @@ -61,7 +68,7 @@ public class RestaurantController { if (cached != null) { try { return objectMapper.readValue(cached, new TypeReference>() {}); - } catch (Exception ignored) {} + } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); } } var result = restaurantService.findAll(limit, offset, cuisine, region, channel); cache.set(key, result); @@ -75,7 +82,7 @@ public class RestaurantController { if (cached != null) { try { return objectMapper.readValue(cached, Restaurant.class); - } catch (Exception ignored) {} + } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); } } var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); @@ -241,6 +248,10 @@ public class RestaurantController { var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); String url = body.get("tabling_url"); + // #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용. + if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용"); + } restaurantService.update(id, Map.of("tabling_url", url != null ? url : "")); cache.flush(); return Map.of("ok", true); @@ -367,6 +378,12 @@ public class RestaurantController { var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); String url = body.get("catchtable_url"); + // #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용. + if (url != null && !url.isBlank() + && !url.startsWith("https://app.catchtable.co.kr/") + && !url.startsWith("https://www.catchtable.co.kr/")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용"); + } restaurantService.update(id, Map.of("catchtable_url", url != null ? url : "")); cache.flush(); return Map.of("ok", true); @@ -379,7 +396,7 @@ public class RestaurantController { if (cached != null) { try { return objectMapper.readValue(cached, new TypeReference>>() {}); - } catch (Exception ignored) {} + } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); } } var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); diff --git a/backend-java/src/main/java/com/tasteby/controller/ReviewController.java b/backend-java/src/main/java/com/tasteby/controller/ReviewController.java index a905912..2bb1cf9 100644 --- a/backend-java/src/main/java/com/tasteby/controller/ReviewController.java +++ b/backend-java/src/main/java/com/tasteby/controller/ReviewController.java @@ -39,7 +39,7 @@ public class ReviewController { @PathVariable String restaurantId, @RequestBody Map body) { String userId = AuthUtil.getUserId(); - double rating = ((Number) body.get("rating")).doubleValue(); + 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; @@ -51,8 +51,7 @@ public class ReviewController { @PathVariable String reviewId, @RequestBody Map body) { String userId = AuthUtil.getUserId(); - Double rating = body.get("rating") != null - ? ((Number) body.get("rating")).doubleValue() : null; + 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; @@ -94,4 +93,18 @@ public class ReviewController { public List 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; + } } diff --git a/backend-java/src/main/java/com/tasteby/service/CacheService.java b/backend-java/src/main/java/com/tasteby/service/CacheService.java index d92728e..f2dba58 100644 --- a/backend-java/src/main/java/com/tasteby/service/CacheService.java +++ b/backend-java/src/main/java/com/tasteby/service/CacheService.java @@ -85,4 +85,14 @@ public class CacheService { log.debug("Cache flush error: {}", e.getMessage()); } } + + // #290 — 단일 키 삭제 (캐시 역직렬화 실패 시 자동 evict 등에 사용) + public void del(String key) { + if (disabled) return; + try { + redis.delete(key); + } catch (Exception e) { + log.debug("Cache del error: {}", e.getMessage()); + } + } } diff --git a/backend-java/src/main/java/com/tasteby/service/ChannelService.java b/backend-java/src/main/java/com/tasteby/service/ChannelService.java index 3240c41..8179370 100644 --- a/backend-java/src/main/java/com/tasteby/service/ChannelService.java +++ b/backend-java/src/main/java/com/tasteby/service/ChannelService.java @@ -27,11 +27,16 @@ public class ChannelService { } public boolean deactivate(String channelId) { - // Try deactivate by channel_id first, then by DB id - int rows = mapper.deactivateByChannelId(channelId); - if (rows == 0) { - rows = mapper.deactivateById(channelId); - } + if (channelId == null || channelId.isBlank()) return false; + // #295 — 입력 형식으로 명시적 분기: + // "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화 + // 그 외(32-char hex UUID 등) → DB id로 비활성화 + // 이전: channel_id 시도 → 0이면 id 시도. 우연히 UC가 hex와 같을 확률은 0이지만 + // 가독성/의도 명확성 + 잘못된 폴백 차단을 위해 명시화. + boolean looksLikeYouTubeId = channelId.startsWith("UC") && channelId.length() == 24; + int rows = looksLikeYouTubeId + ? mapper.deactivateByChannelId(channelId) + : mapper.deactivateById(channelId); return rows > 0; } diff --git a/backend-java/src/main/java/com/tasteby/service/MemoService.java b/backend-java/src/main/java/com/tasteby/service/MemoService.java index cd1dc79..1bb1c43 100644 --- a/backend-java/src/main/java/com/tasteby/service/MemoService.java +++ b/backend-java/src/main/java/com/tasteby/service/MemoService.java @@ -3,6 +3,7 @@ package com.tasteby.service; import com.tasteby.domain.Memo; import com.tasteby.mapper.MemoMapper; import com.tasteby.util.IdGenerator; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,11 +26,18 @@ public class MemoService { @Transactional public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) { String visitedStr = visitedAt != null ? visitedAt.toString() : null; + // #294 — 동시성 가드: 사전 SELECT → 분기 INSERT/UPDATE 패턴은 두 트랜잭션이 동시에 미존재 + // 판정 후 둘 다 INSERT → UNIQUE 충돌(500). INSERT 우선 시도 후 DuplicateKeyException 시 UPDATE. Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId); if (existing != null) { mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr); } else { - mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr); + try { + mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr); + } catch (DuplicateKeyException e) { + // 동시 INSERT 충돌 → UPDATE로 폴백 + mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr); + } } return mapper.findByUserAndRestaurant(userId, restaurantId); } diff --git a/backend-java/src/main/java/com/tasteby/service/ReviewService.java b/backend-java/src/main/java/com/tasteby/service/ReviewService.java index a95754b..ada2a90 100644 --- a/backend-java/src/main/java/com/tasteby/service/ReviewService.java +++ b/backend-java/src/main/java/com/tasteby/service/ReviewService.java @@ -5,6 +5,7 @@ import com.tasteby.domain.Review; import com.tasteby.mapper.ReviewMapper; import com.tasteby.util.IdGenerator; import com.tasteby.util.JsonUtil; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -60,10 +61,15 @@ public class ReviewService { if (existingId != null) { mapper.deleteFavorite(userId, restaurantId); return false; - } else { - mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId); - return true; } + // #294 — 동시성 가드: 동시 INSERT 시 UNIQUE 충돌 → 한 쪽 500. + // INSERT 시도 후 DuplicateKeyException은 "이미 추가됨"으로 간주 (토글 의도는 ON). + try { + mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId); + } catch (DuplicateKeyException ignored) { + // 다른 트랜잭션이 먼저 INSERT 함 — 결과는 어쨌든 즐겨찾기 ON. + } + return true; } public List getUserFavorites(String userId) { diff --git a/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml b/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml index 6c669ca..ae65d4c 100644 --- a/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml @@ -44,7 +44,8 @@ diff --git a/backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml b/backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml index dcc0e7d..d055cbb 100644 --- a/backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml @@ -79,7 +79,8 @@