From d73947444fe326e283aa733e26da9019de5b0711 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 20:32:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20#359=201=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20google=5Fplace=5Fid=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/admin/restaurants/duplicates/place-id (어드민 전용) - 그룹별 식당 목록 + video/review/memo 카운트 동봉 - Mapper: findDuplicatePlaceIdRows + Service 그룹핑 - 정리/병합 + UNIQUE 제약은 데이터 위험 분리 위해 후속 PR로 Refs: #359 (조회 단계 완료) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 7 +++ .../controller/AdminRestaurantController.java | 9 ++++ .../com/tasteby/mapper/RestaurantMapper.java | 3 ++ .../tasteby/service/RestaurantService.java | 17 +++++++ .../mybatis/mapper/RestaurantMapper.xml | 17 +++++++ .../359a-duplicate-place-id-view/README.md | 47 +++++++++++++++++++ 6 files changed, 100 insertions(+) create mode 100644 docs/design/359a-duplicate-place-id-view/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ecf49..85dd286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ ## 2026-06-15 +### 🔍 #359 1단계 — google_place_id 중복 조회 API (v0.1.46) +- GET /api/admin/restaurants/duplicates/place-id (어드민 전용) +- 응답: 그룹별 식당 + video/review/memo 카운트 (병합 의사결정 자료) +- 정리/병합 + UNIQUE 제약은 별도 PR (데이터 위험 분리) +- 설계서: docs/design/359a-duplicate-place-id-view/README.md +- Refs: #359 (조회 단계 완료, 후속 분리 유지) + ### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45) - dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable) - Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max diff --git a/backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java index 062f6f7..d4428a0 100644 --- a/backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java @@ -62,6 +62,15 @@ public class AdminRestaurantController { return Map.of("success", true, "id", id); } + // #359 1단계 — google_place_id 중복 조회 (정리/UNIQUE는 후속). + @GetMapping("/duplicates/place-id") + public Map duplicatePlaceIds() { + var admin = AuthUtil.requireAdmin(); + var groups = restaurantService.findDuplicatePlaceIdGroups(); + log.info("[ADMIN] {} duplicate place_id groups: {}", admin.getSubject(), groups.size()); + return Map.of("groups", groups, "group_count", groups.size()); + } + /** * 어드민용 hidden 토글. */ diff --git a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java index be6eae4..8f270b7 100644 --- a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java +++ b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java @@ -39,6 +39,9 @@ public interface RestaurantMapper { int countUnevaluatedLinks(); + // #359 1단계 — google_place_id 중복 조회 + List> findDuplicatePlaceIdRows(); + Restaurant findById(@Param("id") String id); List> findVideoLinks(@Param("restaurantId") String restaurantId, diff --git a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java index 8448e5d..e1397e6 100644 --- a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java +++ b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java @@ -111,6 +111,23 @@ public class RestaurantService { return mapper.countUnevaluatedLinks(); } + // #359 1단계 — google_place_id 중복 그룹 (참조 카운트 동봉) + public List> findDuplicatePlaceIdGroups() { + var rows = mapper.findDuplicatePlaceIdRows().stream() + .map(JsonUtil::lowerKeys) + .toList(); + Map>> grouped = new LinkedHashMap<>(); + for (var r : rows) { + String key = (String) r.get("google_place_id"); + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(r); + } + List> out = new ArrayList<>(grouped.size()); + for (var e : grouped.entrySet()) { + out.add(Map.of("google_place_id", e.getKey(), "items", e.getValue())); + } + return out; + } + public void update(String id, Map fields) { mapper.updateFields(id, fields); } diff --git a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml index afef3bb..df6fd14 100644 --- a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml @@ -353,4 +353,21 @@ SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL + + + diff --git a/docs/design/359a-duplicate-place-id-view/README.md b/docs/design/359a-duplicate-place-id-view/README.md new file mode 100644 index 0000000..25bbf1f --- /dev/null +++ b/docs/design/359a-duplicate-place-id-view/README.md @@ -0,0 +1,47 @@ +# 설계서: google_place_id 중복 조회 API (#359 1단계) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #359 · 1단계(조회 전용, 위험 0). 2단계(자동 병합) / 3단계(UNIQUE)는 별도 PR. +> · 구현 파일: `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java` +> · 테스트: 본 범위 밖 (수동 — admin token으로 호출). + +## 1. 목적 (Why) + +같은 `google_place_id`에 다중 식당이 매핑된 경우 운영자가 어떤 것을 유지/병합할지 결정 필요. 본 단계는 **조회만** — 그룹과 후보 식당을 메타데이터(연결된 영상/리뷰/메모 수)와 함께 보여줘 의사결정 자료 제공. + +## 2. 범위 + +- 포함: `GET /api/admin/restaurants/duplicates/place-id` — 운영자만, 그룹별 식당 + 카운트 동봉. +- 제외 (별도 PR): 병합/삭제, UNIQUE constraint. + +## 3. 인수조건 + +- [ ] requireAdmin 보호. +- [ ] 응답 구조: `[{ google_place_id, items: [{ id, name, address, created_at, video_count, review_count, memo_count, hidden }] }]`. +- [ ] 그룹은 `COUNT(*) > 1` 만 반환. + +## 4. SQL + +```sql +SELECT r.id, r.google_place_id, r.name, r.address, + TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at, + r.hidden, + (SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count, + (SELECT COUNT(*) FROM reviews rv WHERE rv.restaurant_id = r.id) AS review_count, + (SELECT COUNT(*) FROM memos mm WHERE mm.restaurant_id = r.id) AS memo_count +FROM restaurants r +WHERE r.google_place_id IN ( + SELECT google_place_id FROM restaurants + WHERE google_place_id IS NOT NULL + GROUP BY google_place_id HAVING COUNT(*) > 1 +) +ORDER BY r.google_place_id, r.created_at +``` + +Service 계층에서 google_place_id로 그룹핑하여 응답 구조 변환. + +## 5. 엣지케이스 + +- 중복 0건 → 빈 배열. +- 누군가가 google_place_id를 동시에 변경 중 → 다음 호출에서 반영 (캐시 X).