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:
@@ -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줄 제거
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
// #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
|
||||
Map<String, Object> sanitized = dto.toFieldMap();
|
||||
|
||||
// Re-geocode if name or address changed
|
||||
String newName = (String) sanitized.get("name");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
75
docs/design/358-restaurant-update-dto/README.md
Normal file
75
docs/design/358-restaurant-update-dto/README.md
Normal file
@@ -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<String, Object>`로 흐릿함. 본격 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 — 후속.
|
||||
Reference in New Issue
Block a user