o2o-site-AEO/solution/backend/router/v1/place/protocol.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +09:00

224 lines
8.2 KiB
Python

import uuid
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import ConfigDict
from common.enums import ExternalPlaceSource, JobStatus, LinkChannel, PlaceCategory, PlaceStatus, SourceType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
# 라우터 폴더마다 protocol.py 를 두고 Req_/Res_ 를 정의한다 (protocol 규약).
class PlaceProtocol(WebPacketProtocol):
pass
class Req_CreatePlace(PlaceProtocol):
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
name: str = ""
category: PlaceCategory = PlaceCategory.LODGING
owner_user_id: Optional[uuid.UUID] = None
class Req_VerifyPlace(PlaceProtocol):
"""카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정).
★ 이 단계를 통과해야 수집이 열린다.
external_place_id 는 소스에 따라 없을 수 있다 — 네이버는 고유 장소 id 를 주지 않는다.
그 경우 상호명 + 도로명주소가 중복 판정 키가 되므로 road_address 를 반드시 채워야 한다."""
source: ExternalPlaceSource = ExternalPlaceSource.NAVER
external_place_id: str = ""
# 외부 장소 DB 가 함께 준 업체 홈페이지 URL. 있으면 공식 홈페이지 채널로 자동 등록한다.
# (네이버 지역검색 응답의 link 가 이것 — Perplexity 로는 잘 안 잡히는 채널이라 여기서 건진다.)
place_url: Optional[str] = None
road_address: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
region_code: Optional[str] = None
class Req_VerifyPlaceByUrl(PlaceProtocol):
"""네이버 플레이스 URL 하나로 동일 업소를 확정한다.
★ 왜 이 경로가 필요한가
상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울').
Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다.
그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장
정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다.
서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로
옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다).
"""
url: str = ""
class Req_UpdatePlace(PlaceProtocol):
name: Optional[str] = None
owner_user_id: Optional[uuid.UUID] = None
status: Optional[PlaceStatus] = None
class Req_CreateUnit(PlaceProtocol):
name: str = ""
sort_order: int = 0
class Req_CreateLink(PlaceProtocol):
channel: LinkChannel = LinkChannel.ETC
url: str = ""
title: Optional[str] = None
discovered_by: SourceType = SourceType.OWNER
class PlaceData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
place_id: uuid.UUID
name: str
category: PlaceCategory
status: PlaceStatus
owner_user_id: Optional[uuid.UUID] = None
external_source: Optional[ExternalPlaceSource] = None
external_place_id: Optional[str] = None
road_address: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
region_code: Optional[str] = None
verified_at: Optional[datetime] = None
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
created_at: Optional[datetime] = None
class UnitData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
unit_id: uuid.UUID
place_id: uuid.UUID
name: str
sort_order: int = 0
class LinkData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
link_id: uuid.UUID
place_id: uuid.UUID
channel: LinkChannel
url: str
title: Optional[str] = None
discovered_by: SourceType
discovered_at: Optional[datetime] = None
confirmed_at: Optional[datetime] = None # ★ NULL = 크롤링 대상 아님
class Res_PlaceList(Res_PageProtocol):
places: list[PlaceData] = []
class Res_Place(Res_WebPacketProtocol):
place: Optional[PlaceData] = None
class Res_UnitList(Res_WebPacketProtocol):
units: list[UnitData] = []
class Res_Unit(Res_WebPacketProtocol):
unit: Optional[UnitData] = None
class Res_LinkList(Res_WebPacketProtocol):
links: list[LinkData] = []
confirmed: int = 0 # 크롤링 가능한 링크 수
class Res_Link(Res_WebPacketProtocol):
link: Optional[LinkData] = None
class Req_StartCollect(PlaceProtocol):
"""수집 시작. 몇 분 걸리므로 동기로 처리하지 않고 잡을 적재한 뒤 즉시 응답한다."""
# 확정된 채널 URL 만 크롤링한다. 비우면 이 사업장의 확정 링크 전체.
link_ids: list[uuid.UUID] = []
# 이미 확보한 fact 를 다시 긁을지. 기본은 아니오(외부 API 호출 비용을 아낀다).
force: bool = False
# 상호·주소로 공개 채널 URL 을 Perplexity 에서 추가 탐색할지.
# 기본 False — 사용자가 화면에서 명시적으로 선택한 회차에만 유료 검색을 실행한다.
discover_channels: bool = False
class Res_StartCollect(Res_WebPacketProtocol):
"""수집 잡 적재 결과. 클라이언트는 job_id 로 GET /v1/job/{job_id} 를 폴링한다."""
job_id: Optional[uuid.UUID] = None
status: Optional[JobStatus] = None
# 이미 같은 사업장 수집이 돌고 있어 새로 만들지 않았다면 False (기존 잡의 id 를 돌려준다).
created: bool = True
confirmed_links: int = 0
class Req_StartVision(PlaceProtocol):
"""사진 분석 시작. 사진 20~50장이라 몇 분 걸린다 — 잡으로 처리한다."""
# 이미 분석된 사진도 다시 태울지. 기본은 아니오(같은 사진 재분석은 요금만 나간다).
force: bool = False
class Res_StartVision(Res_WebPacketProtocol):
job_id: Optional[uuid.UUID] = None
status: Optional[JobStatus] = None
created: bool = True
pending_media: int = 0 # 분석 대상 사진 수
# ---- 동일 업소 후보 (UI 가 사람에게 고르게 한다) ----------------------------
class PlaceCandidate(WebPacketProtocol):
"""외부 장소 DB 에서 찾은 후보 1건. UI 가 이걸 카드로 그려 사람이 고른다."""
external_place_id: Optional[str] = None # 네이버는 안 준다
name: str = ""
road_address: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None # 네이버는 안 준다
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
category_name: Optional[str] = None
place_url: Optional[str] = None # 업체 홈페이지 — 확정 시 공식 채널로 등록된다
naver_place_url: Optional[str] = None # 자동 발견한 네이버 플레이스. 없으면 UI가 URL 입력을 요청한다
class Res_VerifyCandidates(Res_WebPacketProtocol):
"""동일 업소 후보 목록.
★ 자동 판정을 신뢰하지 않는다. outcome 이 MATCHED 여도 후보를 전부 내려보내
UI 가 사람에게 확인시킬 수 있게 한다 — 남의 가게가 섞이면 그게 제일 비싼 실수다."""
source: Optional[ExternalPlaceSource] = None
outcome: str = "" # matched | ambiguous | no_candidate
reason: Optional[str] = None # 판정 근거(name_exact / name_duplicate / name_no_exact …)
auto_selectable: bool = False # True 면 UI 가 '이거 맞나요?' 한 번만 물어도 된다
candidates: list[PlaceCandidate] = []
class Req_StartCopy(PlaceProtocol):
"""소개문·FAQ 생성 시작.
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다)."""
pass
class Res_StartCopy(Res_WebPacketProtocol):
job_id: Optional[uuid.UUID] = None
status: Optional[JobStatus] = None
created: bool = True
grounded_facts: int = 0 # 근거로 쓸 수 있는 확인된 fact 수