[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:
parent
7b238efffb
commit
ba90a193f7
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user