From ba90a193f7f923fb969ca953f4eacdba47e8498d Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 10 Sep 2026 14:39:13 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/backend,frontend:=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EA=B0=80=EA=B2=8C=EA=B0=80=20=EC=9C=84=EC=A0=80?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EB=8F=8C=20=EB=95=8C=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EC=83=9D=EA=B8=B0=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 DB 에 '스테이,머뭄' 사업장이 8개 있었다. 전부 같은 네이버 place id(1133638931)이고 그중 하나만 내용이 있다. 사장님이 위저드를 중간에 나갔다 다시 시작하면 그때마다 빈 사업장이 하나씩 쌓인다 — "내 사이트" 목록에 같은 이름이 여러 개 뜨고, 사장님은 어느 것이 자기 사이트인지 알 수 없다. 실제로 이번 테스트에서 빌더가 fact 2건짜리 빈 행을 열고 있어서 "소개가 안 나온다" 로 보였다. ★ 왜 verify 시점인가 위저드는 **신원을 알기 전에** 사업장을 먼저 만든다(ensureServerPlace) — 이름만 아는 빈 행이다. 네이버 place id 를 알게 되는 건 verify_by_url 뿐이고, 그때가 "이미 갖고 있는 그 가게인가" 를 물을 수 있는 첫 지점이다. - crud/place_crud.find_by_external: 같은 사장님의 같은 외부 업소를 찾는다. **쌓인 것이 많은 순**으로 준다(fact+객실+사진+사이트). 처음엔 created_at 순이었는데 그러면 위저드가 만들었다 버린 빈 행이 정본이 됐다(실측: fact 2건짜리가 뽑혔다) — 나이가 아니라 내용이 기준이다. 소유자까지 함께 보는 이유는 외부 id 만으로 찾으면 남의 사업장이 걸리기 때문이다 - place_service.verify_by_url: 정본을 찾으면 그걸 돌려주고, 지금 행이 **비어 있을 때만** 접는다(_is_empty). 사장님이 뭔가 쌓았으면 그건 합치기가 아니라 병합이고 사람이 판단할 일이다 — 그때는 둘 다 남기고 정본만 돌려주며 경고를 남긴다. 세지 못하면 비어 있지 않다고 본다 — 모르면 지우지 않는다 - ensureServerPlace: **서버가 돌려준 place_id 를 쓴다.** 우리가 만든 id 를 계속 붙들면 화면이 접힌 행을 편집하게 되고, 저장은 되는데 목록·발행본은 정본을 봐서 "고쳤는데 반영이 안 된다" 가 된다 검증: 빈 사업장을 새로 만들어 같은 URL 로 검증 → 응답이 정본(99a887f8, weight 40)을 돌려주고 새 행은 접혔다(로그: "빈 행 … 를 접고 … 로 잇는다"). site vitest 51 passed · frontend tsc·eslint 통과 · 백엔드 pytest 529 passed / 52 failed (52건은 이 변경 전 기준선과 동일). --- solution/backend/crud/place_crud.py | 42 +++++++++- solution/backend/services/place_service.py | 84 +++++++++++++++++++ .../features/onboarding/ensureServerPlace.ts | 6 +- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/solution/backend/crud/place_crud.py b/solution/backend/crud/place_crud.py index 3ab125a..f2e5da3 100644 --- a/solution/backend/crud/place_crud.py +++ b/solution/backend/crud/place_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import place_channels, places, place_units +from common.database.model.models import place_channels, place_facts, place_photos, places, place_units, sites from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -64,6 +64,10 @@ class IPlaceCRUD(ABC): async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]: pass + @abstractmethod + async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]: + pass + class PlaceCRUD(IPlaceCRUD): async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: @@ -90,6 +94,42 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]: + """같은 사장님이 **이미 갖고 있는** 같은 외부 업소. 중복 사업장 판정용이다. + + ★ 소유자까지 함께 본다. 외부 id 만으로 찾으면 다른 사장님의 사업장이 걸리고, + 그걸 이어 쓰면 남의 가게를 넘겨받는 셈이 된다. + ★ **쌓인 것이 많은 순**으로 준다. 부르는 쪽은 맨 앞을 정본으로 삼는다. + 한때 `created_at` 오름차순이었는데, 그러면 위저드가 처음 만들었다가 버린 **빈 행**이 + 정본이 되고 정작 fact·객실·사진이 쌓인 행을 접게 된다(실측 2026-09-10: 정본으로 + fact 2건짜리 행이 뽑혔다). 나이가 아니라 **내용**이 기준이다. + 같은 무게면 먼저 만든 쪽이다 — 그 시점부터 사장님이 알고 있던 주소이기 때문이다. + """ + try: + def _count(model): + return ( + select(func.count()) + .select_from(model) + .where(model.place_id == places.place_id, model.deleted == False) # noqa: E712 + .scalar_subquery() + ) + + weight = _count(place_facts) + _count(place_units) + _count(place_photos) + _count(sites) + query = ( + select(places) + .where( + places.owner_user_id == owner_user_id, + places.external_source == source, + places.external_place_id == str(external_place_id), + places.deleted == False, # noqa: E712 + ) + .order_by(weight.desc(), places.created_at.asc()) + ) + return await DB_SESSION_MNG.execute(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + async def list_places( self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int], status: Optional[int], skip: int, limit: int, diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 1a4961b..34f26d7 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -2,6 +2,8 @@ import uuid from fastapi import Depends +from sqlalchemy import func, select + from common.category_schema import CategorySchemaError, get_schema from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import place_channels, places, place_units @@ -189,6 +191,44 @@ class PlaceService: return res # ---- 동일 업소 검증 ---- + async def _is_empty(self, place_id: str) -> bool: + """이 사업장에 **사장님의 것이 쌓였나.** 중복을 접어도 되는지의 판정이다. + + ★ 무엇을 세나: fact · 객실 · 사진 · 사이트. 채널은 세지 않는다 — + 채널은 검증 과정에서 자동으로 붙는 것이라 "사장님이 쌓은 것" 이 아니다. + 이걸 세면 방금 만든 빈 행도 비어 있지 않다고 판정돼 중복이 그대로 남는다. + ★ 하나라도 있으면 접지 않는다. 지우는 쪽이 틀렸을 때의 비용(사장님이 넣은 값이 + 사라진다)이 남기는 쪽이 틀렸을 때의 비용(목록에 하나 더 보인다)보다 훨씬 크다. + ★ 세지 못하면 **비어 있지 않다고 본다** — 모르면 지우지 않는다. + """ + from common.database.model.models import place_facts, place_photos, sites + + pid = uuid.UUID(place_id) + + def _count(model): + return ( + select(func.count()) + .select_from(model) + .where(model.place_id == pid, model.deleted == False) # noqa: E712 + .scalar_subquery() + ) + + query = select( + _count(place_facts) + _count(place_units) + _count(place_photos) + _count(sites) + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, + lambda s: DB_SESSION_MNG.execute(s, query), + ) + if err != ErrorType.SUCCESS or not rows: + LOG.w(f"[verify_by_url] 중복 판정용 계수 실패 place={place_id} — 접지 않는다") + return False + # ★ 이 세션 헬퍼는 한 칸짜리 select 를 스칼라로 풀어서 준다(행 튜플이 아니다). + # `rows[0][0]` 으로 읽으면 TypeError 로 검증 API 가 통째로 500 이 된다(실측). + row = rows[0] + total = row if isinstance(row, int) else row[0] + return int(total) == 0 + async def verify_place_by_url(self, user_info: UserInfo, place_id: str, req: Req_VerifyPlaceByUrl) -> Res_Place: """네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다. @@ -234,6 +274,50 @@ class PlaceService: res.msg = "이 주소에서 상호를 찾지 못했습니다." return res + # ── 중복 사업장 합치기 ──────────────────────────────────────────────── + # ★ 왜 여기인가 + # 위저드는 **신원을 알기 전에** 사업장을 먼저 만든다(`ensureServerPlace`) — 이름만 + # 아는 빈 행이다. 그리고 이 함수에서 비로소 "이 가게가 누구인지"(네이버 place id)를 + # 알게 된다. 그 순간이 "이미 갖고 있는 그 가게인가" 를 물을 수 있는 첫 지점이다. + # 여기서 안 묻고 지나가면 위저드를 다시 시작할 때마다 같은 가게가 하나씩 늘어난다 — + # 실측(2026-09-10): 로컬 DB 에 '스테이,머뭄' 이 8개였고 그중 7개가 fact 2건짜리 + # 빈 행이었다. 사장님은 목록에서 어느 것이 자기 사이트인지 알 수 없다. + # + # ★ 정본은 **먼저 만든 쪽**이다(`find_by_external` 이 오래된 순으로 준다). + # 나중 것을 정본으로 삼으면 앞서 쌓인 fact·사진·발행 이력이 통째로 버려진다. + # + # ★ 지금 행은 **비어 있을 때만** 지운다. 사장님이 이 행에 뭔가를 쌓았다면(fact·객실· + # 사진·발행) 그건 합치기가 아니라 병합이고, 그건 사람이 판단할 일이다 — + # 그때는 둘 다 남기고 정본만 돌려준다. + err_dup, dup_rows = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, + lambda sess: self.crud.find_by_external( + sess, uuid.UUID(user_info.user_id), ExternalPlaceSource.NAVER.value, str(naver_id), + ), + ) + canonical_id = place_id + if err_dup == ErrorType.SUCCESS: + existing = next((r for r in (dup_rows or []) if str(r.place_id) != str(place_id)), None) + if existing is not None: + canonical_id = str(existing.place_id) + if await self._is_empty(place_id): + await DB_SESSION_MNG.execute_lambda_claim( + places.DBType(), + lambda sess: self.crud.delete_place( + sess, uuid.UUID(user_info.user_id), uuid.UUID(place_id), + ), + ) + LOG.i(f"[verify_by_url] 같은 업소가 이미 있다 — 빈 행 {place_id} 를 접고 " + f"{canonical_id} 로 잇는다 (naver {naver_id})") + else: + LOG.w(f"[verify_by_url] 같은 업소가 둘이다 — {place_id} 에 쌓인 것이 있어 " + f"지우지 않는다. 정본 {canonical_id} 를 돌려준다 (naver {naver_id})") + place_id = canonical_id + err_type, place = await self._load(user_info, place_id) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + coord = base.get("coordinate") or {} verify_req = Req_VerifyPlace( source=ExternalPlaceSource.NAVER, diff --git a/solution/frontend/src/features/onboarding/ensureServerPlace.ts b/solution/frontend/src/features/onboarding/ensureServerPlace.ts index f0d52c9..0c88d1c 100644 --- a/solution/frontend/src/features/onboarding/ensureServerPlace.ts +++ b/solution/frontend/src/features/onboarding/ensureServerPlace.ts @@ -65,9 +65,13 @@ export async function ensureServerPlace( } // ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라 // 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다. + // ★ **place_id 도 서버가 돌려준 것을 쓴다.** 같은 가게를 이미 갖고 있으면 서버가 + // 그 정본을 돌려주고 방금 만든 빈 행을 접는다(`verify_place_by_url` 의 중복 합치기). + // 여기서 우리가 만든 id 를 계속 붙들면, 화면은 **접힌 행**을 편집하게 된다 — + // 저장은 되는데 목록·발행본은 정본을 보므로 "고쳤는데 반영이 안 된다" 가 된다. return { ...identity, - placeId, + placeId: place.place_id ?? placeId, name: place.name ?? identity.name, address: place.road_address ?? place.address ?? identity.address, phone: place.phone ?? identity.phone,