[fix] solution/backend,frontend: 같은 가게가 위저드를 돌 때마다 새로 생기던 것

로컬 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건은 이 변경 전 기준선과 동일).
This commit is contained in:
hbyang 2026-09-10 14:39:13 +09:00
parent 7b238efffb
commit ba90a193f7
3 changed files with 130 additions and 2 deletions

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG 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.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime 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]: async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]:
pass pass
@abstractmethod
async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]:
pass
class PlaceCRUD(IPlaceCRUD): class PlaceCRUD(IPlaceCRUD):
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
@ -90,6 +94,42 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None 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( async def list_places(
self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int], self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int],
status: Optional[int], skip: int, limit: int, status: Optional[int], skip: int, limit: int,

View File

@ -2,6 +2,8 @@ import uuid
from fastapi import Depends from fastapi import Depends
from sqlalchemy import func, select
from common.category_schema import CategorySchemaError, get_schema from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG 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, places, place_units
@ -189,6 +191,44 @@ class PlaceService:
return res 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: async def verify_place_by_url(self, user_info: UserInfo, place_id: str, req: Req_VerifyPlaceByUrl) -> Res_Place:
"""네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다. """네이버 플레이스 URL → 상호·주소·좌표를 읽어 동일 업소를 확정하고, 그 URL 을 수집 채널로 등록한다.
@ -234,6 +274,50 @@ class PlaceService:
res.msg = "이 주소에서 상호를 찾지 못했습니다." res.msg = "이 주소에서 상호를 찾지 못했습니다."
return res 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 {} coord = base.get("coordinate") or {}
verify_req = Req_VerifyPlace( verify_req = Req_VerifyPlace(
source=ExternalPlaceSource.NAVER, source=ExternalPlaceSource.NAVER,

View File

@ -65,9 +65,13 @@ export async function ensureServerPlace(
} }
// ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라 // ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라
// 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다. // 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다.
// ★ **place_id 도 서버가 돌려준 것을 쓴다.** 같은 가게를 이미 갖고 있으면 서버가
// 그 정본을 돌려주고 방금 만든 빈 행을 접는다(`verify_place_by_url` 의 중복 합치기).
// 여기서 우리가 만든 id 를 계속 붙들면, 화면은 **접힌 행**을 편집하게 된다 —
// 저장은 되는데 목록·발행본은 정본을 보므로 "고쳤는데 반영이 안 된다" 가 된다.
return { return {
...identity, ...identity,
placeId, placeId: place.place_id ?? placeId,
name: place.name ?? identity.name, name: place.name ?? identity.name,
address: place.road_address ?? place.address ?? identity.address, address: place.road_address ?? place.address ?? identity.address,
phone: place.phone ?? identity.phone, phone: place.phone ?? identity.phone,