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 โ€” ํ›„์†.