Compare commits

..

2 Commits

Author SHA1 Message Date
joungmin
5579c5b00f docs(changelog): v0.1.20 백엔드 CRUD 결함 기록 (#290+#294+#295) 2026-06-15 14:16:41 +09:00
joungmin
4b02293046 fix(crud): P4-1 백엔드 CRUD 결함 일괄 수정 (#290+#294+#295)
#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
2026-06-15 14:14:41 +09:00
10 changed files with 96 additions and 22 deletions

View File

@@ -6,6 +6,13 @@
## 2026-06-15 ## 2026-06-15
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
- Refs: #290 #294 #295 (close)
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19) ### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단) - SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착 - SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착

View File

@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.ChannelService; import com.tasteby.service.ChannelService;
import com.tasteby.service.YouTubeService; import com.tasteby.service.YouTubeService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -52,16 +53,21 @@ public class ChannelController {
String channelId = body.get("channel_id"); String channelId = body.get("channel_id");
String channelName = body.get("channel_name"); String channelName = body.get("channel_name");
String titleFilter = body.get("title_filter"); 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 { try {
String id = channelService.create(channelId, channelName, titleFilter); String id = channelService.create(channelId, channelName, titleFilter);
cache.flush(); cache.flush();
return Map.of("id", id, "channel_id", channelId); return Map.of("id", id, "channel_id", channelId);
} catch (Exception e) { } catch (DataIntegrityViolationException e) {
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) { // #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
} }
throw e;
}
} }
@PostMapping("/{channelId}/scan") @PostMapping("/{channelId}/scan")

View File

@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService; import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService; import com.tasteby.service.RestaurantService;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -47,6 +48,12 @@ public class RestaurantController {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
@PreDestroy
public void shutdownExecutor() {
executor.shutdown();
}
@GetMapping @GetMapping
public List<Restaurant> list( public List<Restaurant> list(
@RequestParam(defaultValue = "100") int limit, @RequestParam(defaultValue = "100") int limit,
@@ -61,7 +68,7 @@ public class RestaurantController {
if (cached != null) { if (cached != null) {
try { try {
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {}); return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
} 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); var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
cache.set(key, result); cache.set(key, result);
@@ -75,7 +82,7 @@ public class RestaurantController {
if (cached != null) { if (cached != null) {
try { try {
return objectMapper.readValue(cached, Restaurant.class); 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); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
@@ -241,6 +248,10 @@ public class RestaurantController {
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("tabling_url"); 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 : "")); restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
@@ -367,6 +378,12 @@ public class RestaurantController {
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("catchtable_url"); 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 : "")); restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
@@ -379,7 +396,7 @@ public class RestaurantController {
if (cached != null) { if (cached != null) {
try { try {
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {}); return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
} catch (Exception ignored) {} } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
} }
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");

View File

@@ -39,7 +39,7 @@ public class ReviewController {
@PathVariable String restaurantId, @PathVariable String restaurantId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId(); String userId = AuthUtil.getUserId();
double rating = ((Number) body.get("rating")).doubleValue(); double rating = requireRating(body.get("rating"));
String text = (String) body.get("review_text"); String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null; ? LocalDate.parse((String) body.get("visited_at")) : null;
@@ -51,8 +51,7 @@ public class ReviewController {
@PathVariable String reviewId, @PathVariable String reviewId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId(); String userId = AuthUtil.getUserId();
Double rating = body.get("rating") != null Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
? ((Number) body.get("rating")).doubleValue() : null;
String text = (String) body.get("review_text"); String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null; ? LocalDate.parse((String) body.get("visited_at")) : null;
@@ -94,4 +93,18 @@ public class ReviewController {
public List<Restaurant> myFavorites() { public List<Restaurant> myFavorites() {
return reviewService.getUserFavorites(AuthUtil.getUserId()); 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;
}
} }

View File

@@ -85,4 +85,14 @@ public class CacheService {
log.debug("Cache flush error: {}", e.getMessage()); 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());
}
}
} }

View File

@@ -27,11 +27,16 @@ public class ChannelService {
} }
public boolean deactivate(String channelId) { public boolean deactivate(String channelId) {
// Try deactivate by channel_id first, then by DB id if (channelId == null || channelId.isBlank()) return false;
int rows = mapper.deactivateByChannelId(channelId); // #295 — 입력 형식으로 명시적 분기:
if (rows == 0) { // "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
rows = mapper.deactivateById(channelId); // 그 외(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; return rows > 0;
} }

View File

@@ -3,6 +3,7 @@ package com.tasteby.service;
import com.tasteby.domain.Memo; import com.tasteby.domain.Memo;
import com.tasteby.mapper.MemoMapper; import com.tasteby.mapper.MemoMapper;
import com.tasteby.util.IdGenerator; import com.tasteby.util.IdGenerator;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -25,11 +26,18 @@ public class MemoService {
@Transactional @Transactional
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) { public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null; String visitedStr = visitedAt != null ? visitedAt.toString() : null;
// #294 — 동시성 가드: 사전 SELECT → 분기 INSERT/UPDATE 패턴은 두 트랜잭션이 동시에 미존재
// 판정 후 둘 다 INSERT → UNIQUE 충돌(500). INSERT 우선 시도 후 DuplicateKeyException 시 UPDATE.
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId); Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
if (existing != null) { if (existing != null) {
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr); mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
} else { } else {
try {
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr); 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); return mapper.findByUserAndRestaurant(userId, restaurantId);
} }

View File

@@ -5,6 +5,7 @@ import com.tasteby.domain.Review;
import com.tasteby.mapper.ReviewMapper; import com.tasteby.mapper.ReviewMapper;
import com.tasteby.util.IdGenerator; import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil; import com.tasteby.util.JsonUtil;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -60,10 +61,15 @@ public class ReviewService {
if (existingId != null) { if (existingId != null) {
mapper.deleteFavorite(userId, restaurantId); mapper.deleteFavorite(userId, restaurantId);
return false; 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<Restaurant> getUserFavorites(String userId) { public List<Restaurant> getUserFavorites(String userId) {

View File

@@ -44,7 +44,8 @@
</update> </update>
<select id="findByChannelId" resultMap="channelResultMap"> <select id="findByChannelId" resultMap="channelResultMap">
SELECT id, channel_id, channel_name, title_filter <!-- #295 — findAllActive와 동일하게 description/tags/sort_order까지 SELECT -->
SELECT id, channel_id, channel_name, title_filter, description, tags, sort_order
FROM channels FROM channels
WHERE channel_id = #{channelId} AND is_active = 1 WHERE channel_id = #{channelId} AND is_active = 1
</select> </select>

View File

@@ -79,7 +79,8 @@
</select> </select>
<select id="getAvgRating" resultType="map"> <select id="getAvgRating" resultType="map">
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count <!-- #294 — review 0건이면 AVG는 NULL → 클라이언트 NaN 처리 부담. NVL로 0.0 보장. -->
SELECT NVL(ROUND(AVG(rating), 1), 0) AS avg_rating, COUNT(*) AS review_count
FROM user_reviews FROM user_reviews
WHERE restaurant_id = #{restaurantId} WHERE restaurant_id = #{restaurantId}
</select> </select>