feat(backend): #358 RestaurantUpdateDTO + @Valid 표준화

- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)

Refs: #358 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-06-15 20:20:51 +09:00
parent a504bf8ee5
commit c1050f3abd
4 changed files with 183 additions and 19 deletions

View File

@@ -4,10 +4,12 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil;
import com.tasteby.dto.RestaurantUpdateDTO;
import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService;
import com.tasteby.service.WebSearchService;
import jakarta.validation.Valid;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -84,30 +86,14 @@ public class RestaurantController {
return r;
}
// #332 — Restaurant 업데이트 화이트리스트 (SQL updateFields의 컬럼 가드와 1:1).
// 허용되지 않은 키는 무시(silent drop). DTO 도입은 후속 작업.
private static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
"name", "address", "region", "cuisine_type", "price_range",
"phone", "website", "tabling_url", "catchtable_url",
"latitude", "longitude", "google_place_id",
"business_status", "rating", "rating_count"
);
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
public Map<String, Object> update(@PathVariable String id, @Valid @RequestBody RestaurantUpdateDTO dto) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
// #332입력 body를 허용 키만 통과시킨 가변 Map으로 정규화
Map<String, Object> sanitized = new java.util.LinkedHashMap<>();
for (var e : body.entrySet()) {
if (ALLOWED_UPDATE_FIELDS.contains(e.getKey())) {
sanitized.put(e.getKey(), e.getValue());
} else {
log.debug("Ignoring non-whitelisted update field: {}", e.getKey());
}
}
// #358DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
Map<String, Object> sanitized = dto.toFieldMap();
// Re-geocode if name or address changed
String newName = (String) sanitized.get("name");

View File

@@ -0,0 +1,94 @@
package com.tasteby.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* #358 식당 부분 업데이트 DTO.
* - null = 변경 없음 (toFieldMap에서 제외).
* - 화이트리스트는 record 필드로 표현 — Jackson SNAKE_CASE 매핑 유지.
* - URL: http(s) / "NONE" / 빈 문자열만 허용 ("NONE"은 DDG/Naver 매칭 실패 마킹).
*/
public record RestaurantUpdateDTO(
@Size(min = 1, max = 200)
String name,
@Size(max = 500)
String address,
@Size(max = 100)
String region,
@JsonProperty("cuisine_type")
@Size(max = 50)
String cuisineType,
@JsonProperty("price_range")
@Min(1) @Max(5)
Integer priceRange,
@Size(max = 50)
String phone,
@Pattern(regexp = "^(https?://.*|NONE|)$")
String website,
@JsonProperty("tabling_url")
@Pattern(regexp = "^(https?://.*|NONE|)$")
String tablingUrl,
@JsonProperty("catchtable_url")
@Pattern(regexp = "^(https?://.*|NONE|)$")
String catchtableUrl,
@DecimalMin("-90.0") @DecimalMax("90.0")
BigDecimal latitude,
@DecimalMin("-180.0") @DecimalMax("180.0")
BigDecimal longitude,
@JsonProperty("google_place_id")
@Size(max = 200)
String googlePlaceId,
@JsonProperty("business_status")
@Size(max = 50)
String businessStatus,
@DecimalMin("0.0") @DecimalMax("5.0")
BigDecimal rating,
@JsonProperty("rating_count")
@Min(0)
Integer ratingCount
) {
/** null이 아닌 필드만 DB 컬럼명 키로 변환. */
public Map<String, Object> toFieldMap() {
Map<String, Object> m = new LinkedHashMap<>();
if (name != null) m.put("name", name);
if (address != null) m.put("address", address);
if (region != null) m.put("region", region);
if (cuisineType != null) m.put("cuisine_type", cuisineType);
if (priceRange != null) m.put("price_range", priceRange);
if (phone != null) m.put("phone", phone);
if (website != null) m.put("website", website);
if (tablingUrl != null) m.put("tabling_url", tablingUrl);
if (catchtableUrl != null) m.put("catchtable_url", catchtableUrl);
if (latitude != null) m.put("latitude", latitude);
if (longitude != null) m.put("longitude", longitude);
if (googlePlaceId != null) m.put("google_place_id", googlePlaceId);
if (businessStatus != null) m.put("business_status", businessStatus);
if (rating != null) m.put("rating", rating);
if (ratingCount != null) m.put("rating_count", ratingCount);
return m;
}
}