From c1050f3abd488ecb44de9e65d07942e3f70e72ca Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 20:20:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20#358=20RestaurantUpdateDTO=20+?= =?UTF-8?q?=20@Valid=20=ED=91=9C=EC=A4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 9 ++ .../controller/RestaurantController.java | 24 +---- .../com/tasteby/dto/RestaurantUpdateDTO.java | 94 +++++++++++++++++++ .../358-restaurant-update-dto/README.md | 75 +++++++++++++++ 4 files changed, 183 insertions(+), 19 deletions(-) create mode 100644 backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java create mode 100644 docs/design/358-restaurant-update-dto/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab2d50..f4ecf49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ ## 2026-06-15 +### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45) +- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable) +- Bean Validation: @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 필드 자체가 화이트리스트) +- 설계서: docs/design/358-restaurant-update-dto/README.md +- Refs: #358 (close) + ### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44) - WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백) - RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거 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 32cab49..fcde880 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -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 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 update(@PathVariable String id, @RequestBody Map body) { + public Map 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 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()); - } - } + // #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현. + Map sanitized = dto.toFieldMap(); // Re-geocode if name or address changed String newName = (String) sanitized.get("name"); diff --git a/backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java b/backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java new file mode 100644 index 0000000..332404a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java @@ -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 toFieldMap() { + Map 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; + } +} diff --git a/docs/design/358-restaurant-update-dto/README.md b/docs/design/358-restaurant-update-dto/README.md new file mode 100644 index 0000000..5219018 --- /dev/null +++ b/docs/design/358-restaurant-update-dto/README.md @@ -0,0 +1,75 @@ +# 설계서: RestaurantUpdateDTO + @Valid 표준화 (#358) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #358 · 부모: #348(09-Done) · 관련: #332(화이트리스트 1차) +> · 구현 파일: `backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java` +> · 테스트: 본 범위 밖 (수동 — 어드민 식당 편집 동작 확인) + +## 1. 목적 (Why) + +`#332`에서 Set 화이트리스트로 1차 가드 적용했지만, 타입 안전성·validation·API 명세는 여전히 `Map`로 흐릿함. 본격 DTO 표준화로 잘못된 입력 자동 거부 + 명세 명확화. + +## 2. 범위 (Scope) + +- **포함** + - `RestaurantUpdateDTO` record — 화이트리스트 14필드 모두 Optional(null 시 미변경). + - `@Valid` + Bean Validation 어노테이션 적용 (`@Size`, `@Pattern`, `@DecimalMin/Max`, `@Min`). + - Controller `PUT /api/restaurants/{id}` 시그니처: `Map → RestaurantUpdateDTO`. + - DTO → Map 변환(`toFieldMap()`) — Service 계층은 그대로 (재작업 0). + - 잘못된 입력 시 400 자동 응답 (Spring 기본 `MethodArgumentNotValidException`). +- **제외 (별도 후속)** + - `tabling-url` / `catchtable-url` PUT 엔드포인트 — 단일 필드라 현행 유지. + - PATCH 시멘틱 (부분 업데이트) — 현재 PUT이 부분 업데이트 의미로 사용 중. + +## 3. 인수조건 + +- [ ] 모든 화이트리스트 필드 record에 등재 + null 가능. +- [ ] `name`: `@Size(min=1, max=200)`. +- [ ] `website`/`tabling_url`/`catchtable_url`: `@Pattern(http(s)://... | "NONE" | "")`. +- [ ] `latitude`: `@DecimalMin("-90.0") @DecimalMax("90.0")`. +- [ ] `longitude`: `@DecimalMin("-180.0") @DecimalMax("180.0")`. +- [ ] `rating`: `@DecimalMin("0.0") @DecimalMax("5.0")`. +- [ ] `rating_count`: `@Min(0)`. +- [ ] `price_range`: `@Min(1) @Max(5)`. +- [ ] 잘못된 입력 → HTTP 400 자동 응답. +- [ ] 기존 동작 회귀 없음 (geocode/cache flush 흐름 동일). + +## 4. 컨텍스트 & 제약 + +- `spring-boot-starter-validation` 이미 의존성 등록됨. +- record + Bean Validation: 컴파일 시 어노테이션 인식 OK. +- Jackson SNAKE_CASE 매핑 유지: `cuisine_type`, `tabling_url` 등. +- `null`은 "변경 없음" 시그널 — `toFieldMap()`에서 제외. + +## 5. 함수 명세 + +| 함수 | 책임 | 비고 | +|---|---|---| +| `RestaurantUpdateDTO` (record) | 입력 표면 | 14 필드, 모두 nullable | +| `RestaurantUpdateDTO.toFieldMap()` | null 제외 Map 변환 | Service `update` 시그니처 유지 | +| `RestaurantController.update(...)` | DTO 받음 + geocode 분기 | `@Valid @RequestBody RestaurantUpdateDTO` | + +## 6. 흐름 + +1. 클라이언트 → `PUT /api/restaurants/{id}` JSON. +2. Spring 역직렬화 + Bean Validation. 실패 시 400 자동. +3. `dto.toFieldMap()` → null 제외. +4. 기존 geocode 분기 + `restaurantService.update(id, fieldMap)`. + +## 7. 엣지케이스 + +- **모든 필드 null**: `toFieldMap()` 빈 Map → no-op (현행 유지). +- **`tabling_url = "NONE"` / 빈 문자열**: Pattern에 포함 → 통과. +- **숫자 범위 위반**: 400. +- **알 수 없는 필드 (예: `xxx`)**: Jackson 기본은 무시 (mapper 설정 유지) → 안전. + +## 8. 리스크 & 대안 + +- **선택**: record + Bean Validation. 코드 최소. +- **대안 A**: class + setter. 보일러플레이트 다수. +- **대안 B**: 개별 PATCH endpoint per 필드. 표면 폭증. + +## 9. 미해결 질문 + +- bulkUpdate (batch) 도입 시 별도 DTO — 후속.