From 1c255152d73da016415806a9f5475d3c12372c31 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 25 Apr 2026 12:51:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(travel-proxy):=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=EB=B3=80=EA=B2=BD=20API=20+=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EB=A9=94=ED=83=80=20API=20+=20region=5Fmap=5Fextra?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PUT /api/travel/albums/{album}/region: 앨범의 지역 변경 (extra 파일 기반) - PUT /api/travel/regions/{region_id}: 커스텀 지역 이름/좌표 수정 (Phase 2 준비) - _load_extra/_save_extra 헬퍼 분리, _removes 키로 원본 오버라이드 지원 - regions API: 모든 커스텀 지역 동적 병합 + Point geometry 지원 Co-Authored-By: Claude Opus 4.6 --- travel-proxy/app/indexer.py | 147 +++++++++++++++++++++++++++++------- travel-proxy/app/main.py | 67 ++++++++++++++-- 2 files changed, 182 insertions(+), 32 deletions(-) diff --git a/travel-proxy/app/indexer.py b/travel-proxy/app/indexer.py index 23b23a7..53cd848 100644 --- a/travel-proxy/app/indexer.py +++ b/travel-proxy/app/indexer.py @@ -77,51 +77,144 @@ def _load_region_map_merged(region_map_path: Path, thumb_root: Path) -> dict: raise # 오버라이드 파일 병합 - extra_path = thumb_root / "region_map_extra.json" - if extra_path.exists(): - try: - with open(extra_path, "r", encoding="utf-8") as f: - extra = json.load(f) - for k, v in extra.items(): - if k in region_map: - # 기존 지역에 앨범 추가 (중복 방지) - existing = region_map[k] - if isinstance(existing, list) and isinstance(v, list): - merged = list(dict.fromkeys(existing + v)) - region_map[k] = merged - else: - region_map[k] = v - except (json.JSONDecodeError, OSError) as e: - logger.warning("Failed to load region_map_extra: %s", e) + extra = _load_extra(thumb_root) + if extra: + # _removes: 원본에서 제거할 항목 적용 + removes = extra.get("_removes", {}) + for region_id, remove_albums in removes.items(): + if region_id in region_map: + existing = region_map[region_id] + if isinstance(existing, list): + region_map[region_id] = [a for a in existing if a not in remove_albums] + elif isinstance(existing, dict) and "albums" in existing: + existing["albums"] = [a for a in existing["albums"] if a not in remove_albums] + + # 나머지 키 병합 (_removes, _regions_meta 제외) + for k, v in extra.items(): + if k.startswith("_"): + continue + if k in region_map: + existing = region_map[k] + if isinstance(existing, list) and isinstance(v, list): + merged = list(dict.fromkeys(existing + v)) + region_map[k] = merged + else: + region_map[k] = v return region_map +def _extra_path(thumb_root: Path) -> Path: + return thumb_root / "region_map_extra.json" + + +def _load_extra(thumb_root: Path) -> dict: + """RW 오버라이드 파일 로드.""" + path = _extra_path(thumb_root) + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + return {} + + +def _save_extra(thumb_root: Path, data: dict) -> None: + """RW 오버라이드 파일 저장.""" + path = _extra_path(thumb_root) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def _save_extra_albums(thumb_root: Path, new_folders: List[str]) -> None: """신규 발견 폴더를 RW 영역의 오버라이드 파일에 저장.""" - extra_path = thumb_root / "region_map_extra.json" - extra = {} - if extra_path.exists(): - try: - with open(extra_path, "r", encoding="utf-8") as f: - extra = json.load(f) - except (json.JSONDecodeError, OSError): - extra = {} - + extra = _load_extra(thumb_root) uncategorized = extra.get("미분류", []) + if isinstance(uncategorized, dict): + uncategorized = uncategorized.get("albums", []) existing = set(uncategorized) added = [f for f in new_folders if f not in existing] if added: uncategorized.extend(added) extra["미분류"] = uncategorized try: - with open(extra_path, "w", encoding="utf-8") as f: - json.dump(extra, f, ensure_ascii=False, indent=2) + _save_extra(thumb_root, extra) logger.info("Saved %d new folders to region_map_extra.json → 미분류", len(added)) except OSError as e: logger.warning("Cannot write region_map_extra: %s", e) +def move_album_region( + thumb_root: Path, + region_map_path: Path, + album: str, + new_region: str, +) -> dict: + """ + 앨범의 지역을 변경. extra 파일에서 이전 지역에서 제거 + 새 지역에 추가. + 원본 region_map.json의 매핑을 오버라이드 하기 위해 + extra에 _remove 키로 원본에서 제거할 항목을 기록. + + Returns: {"album": str, "old_region": str, "new_region": str} + """ + # 현재 병합 맵에서 앨범의 기존 지역 조회 + merged = _load_region_map_merged(region_map_path, thumb_root) + old_region = None + for region_id, v in merged.items(): + album_list = v if isinstance(v, list) else v.get("albums", []) + if album in album_list: + old_region = region_id + break + + extra = _load_extra(thumb_root) + + # 1. 기존 지역에서 제거 + if old_region: + # extra에 해당 지역의 앨범 목록이 있으면 거기서 제거 + if old_region in extra: + old_list = extra[old_region] + if isinstance(old_list, dict): + old_list = old_list.get("albums", []) + if album in old_list: + old_list.remove(album) + extra[old_region] = old_list + + # 원본 region_map에 있는 경우 _overrides로 제거 기록 + try: + with open(region_map_path, "r", encoding="utf-8") as f: + original = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + original = {} + + orig_list = original.get(old_region, []) + if isinstance(orig_list, dict): + orig_list = orig_list.get("albums", []) + if album in orig_list: + removes = extra.setdefault("_removes", {}) + remove_list = removes.setdefault(old_region, []) + if album not in remove_list: + remove_list.append(album) + + # 2. 새 지역에 추가 + new_list = extra.get(new_region, []) + if isinstance(new_list, dict): + new_list = new_list.get("albums", []) + if album not in new_list: + new_list.append(album) + extra[new_region] = new_list + + # _removes에서 새 지역의 해당 앨범은 제거 (되돌리기 대응) + if "_removes" in extra and new_region in extra["_removes"]: + if album in extra["_removes"][new_region]: + extra["_removes"][new_region].remove(album) + + _save_extra(thumb_root, extra) + logger.info("Moved album '%s': %s → %s", album, old_region or "없음", new_region) + + return {"album": album, "old_region": old_region or "미분류", "new_region": new_region} + + def sync( travel_root: Path, thumb_root: Path, diff --git a/travel-proxy/app/main.py b/travel-proxy/app/main.py index 3a8fe1b..a07b076 100644 --- a/travel-proxy/app/main.py +++ b/travel-proxy/app/main.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from PIL import Image from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, mark_thumb_done -from .indexer import sync, _load_region_map_merged +from .indexer import sync, _load_region_map_merged, move_album_region logging.basicConfig( level=logging.INFO, @@ -109,6 +109,15 @@ class CoverRequest(BaseModel): filename: str +class RegionRequest(BaseModel): + region: str + + +class RegionMetaRequest(BaseModel): + name: str | None = None + coordinates: list[float] | None = None # [lng, lat] + + # ----------------------------- # Routes # ----------------------------- @@ -120,18 +129,40 @@ def health(): @app.get("/api/travel/regions") def regions(): geojson = load_regions_geojson() - # 미분류 지역이 region_map에 있으면 GeoJSON에도 동적 추가 region_map = load_region_map() + + # GeoJSON에 이미 있는 지역 ID 수집 existing_ids = { f.get("properties", {}).get("id") for f in geojson.get("features", []) } - if "미분류" in region_map and "미분류" not in existing_ids: + + # region_map에 있지만 GeoJSON에 없는 커스텀 지역을 동적 추가 + # _regions_meta에 좌표가 있으면 Point geometry로, 없으면 null + from .indexer import _load_extra + extra = _load_extra(THUMB_ROOT) + regions_meta = extra.get("_regions_meta", {}) + + for region_id in region_map: + if region_id in existing_ids: + continue + meta = regions_meta.get(region_id, {}) + coords = meta.get("coordinates") # [lng, lat] or None + geometry = ( + {"type": "Point", "coordinates": coords} + if coords and len(coords) == 2 + else None + ) geojson.setdefault("features", []).append({ "type": "Feature", - "properties": {"id": "미분류", "name": "미분류"}, - "geometry": None, + "properties": { + "id": region_id, + "name": meta.get("name", region_id), + "custom": True, + }, + "geometry": geometry, }) + return geojson @@ -216,6 +247,32 @@ def albums_list(): return result +@app.put("/api/travel/regions/{region_id}") +def update_region_meta(region_id: str, body: RegionMetaRequest): + """커스텀 지역의 이름/좌표 수정 (Phase 2: 지도 핀 표시용).""" + from .indexer import _load_extra, _save_extra + extra = _load_extra(THUMB_ROOT) + meta = extra.setdefault("_regions_meta", {}) + entry = meta.setdefault(region_id, {}) + if body.name is not None: + entry["name"] = body.name + if body.coordinates is not None: + if len(body.coordinates) != 2: + raise HTTPException(400, "coordinates must be [lng, lat]") + entry["coordinates"] = body.coordinates + _save_extra(THUMB_ROOT, extra) + return {"region_id": region_id, **entry} + + +@app.put("/api/travel/albums/{album}/region") +def set_region(album: str, body: RegionRequest): + new_region = body.region.strip() + if not new_region: + raise HTTPException(400, "Region name cannot be empty") + result = move_album_region(THUMB_ROOT, REGION_MAP_PATH, album, new_region) + return result + + @app.put("/api/travel/albums/{album}/cover") def set_cover(album: str, body: CoverRequest): ok = set_album_cover(album, body.filename)