o2o-site-AEO/solution/backend/router/v1/place/protocol.py
Mina Choi 079c93a62a [feat] solution,postgres-init,docs: 생성 진행 상태 · 새로 만들기 존중 · 발행본 색인·파비콘
작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다.

## 1. 콘텐츠 생성 진행 상태 (작업트리에 있던 것)

COPY 잡의 실제 단계를 DB에 기록하고 응답으로 내보낸다. 폴링 횟수로 진행률을 흉내 내던
것을 걷어냈다. 새로고침·재접속해도 jobId 로 이어서 본다.

- services/copy_steps.py · services/job_progress.py · common/job_errors.py (신규)
- postgres-init/migrations/0013_job_progress.sql + init.sql
- 프론트: useGenerationJob · generationLabels (신규), Step5Generating·pollJob 배선,
  orval 모델 갱신(jobProgress · jobStep · jobStepStatus · jobStepReason)
- docs/GENERATION_FLOW.md (신규)

## 2. 발행된 사이트만 색인한다

실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가 짓다 만 상태로
구글에 실려 있었다는 뜻이다.

head.ts 가 robots 를 하드코딩하고 payload 의 `site.status` 를 보지 않았다.
"색인을 막을 이유가 없다"는 주석은 굽는 것이 곧 발행이던 시절의 말인데, 지금은 빌더
미리보기만 눌러도 draft 로 구워진다.

- seo/head.ts: PUBLISHED 일 때만 index, 아니면 `noindex, follow`
- 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — 그쪽은 구운 HTML 의 robots 를 읽어
  거른다(seo/directory.ts readBakedNoindex). 규칙을 두 자리에 두지 않으려고 한 곳에 뒀다

## 3. [새로 크롤링하고 사이트 생성하기] 를 뒤집지 않는다

ba90a19 의 중복 합치기가 **일부러 다시 만들려는 경우까지** 기존 사업장으로 끌고 갔다 —
새로 만들기를 눌렀는데 기존 에디터가 열린다(사장님 보고 2026-09-15).

- Req_VerifyPlaceByUrl.reuse_existing (기본 True — 다른 호출자의 동작은 그대로)
- place_service.verify_place_by_url: 끄면 이어붙이지 않는다. 다만 **비어 있는 중복 행은
  계속 치운다** — 원래 막으려던 누적이 그것이고 빈 행은 잃을 것이 없다
- ensureServerPlace: 위저드는 새로 만들기 경로에서만 오므로 False 로 보낸다

## 4. 발행본 파비콘

발행본에 파비콘 링크가 아예 없어 브라우저 탭에 기본 아이콘이 떴다. 파일은 오리진 루트의
공용 자산이라 사이트마다 복사하지 않고 루트 절대경로로 가리킨다.

검증: site vitest 84건 통과 · tsc(site·frontend) · eslint 통과.
백엔드 pytest 는 로컬 DB 비밀번호가 맞지 않아 돌리지 못했다(a5b8701 과 같은 자리).
발행본 반영에는 전체 재굽기가 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:29:27 +09:00

268 lines
11 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
# ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
# 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
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
# 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다.
category_name: Optional[str] = None
class Req_VerifyPlaceByUrl(PlaceProtocol):
"""네이버 플레이스 URL 하나로 동일 업소를 확정한다.
★ 왜 이 경로가 필요한가
상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울').
Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다.
그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장
정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다.
서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로
옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다).
"""
url: str = ""
# ★ 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지.
#
# 기본값이 True 인 것은 이 API 를 부르는 다른 자리(주소 재확인 등)의 동작을 바꾸지
# 않기 위해서다. 위저드의 **[새로 크롤링하고 사이트 생성하기]** 는 False 로 보낸다 —
# 사장님이 새로 만들겠다고 누른 것을 서버가 "이미 있으니 그걸 쓰세요" 로 바꿔 버리면
# 같은 화면을 눌러도 기존 에디터가 열린다(실측 2026-09-15: 그게 지금 증상이다).
#
# False 라도 **비어 있는 중복 행은 치운다.** 그건 위저드를 중간에 나갔을 때 남는
# 찌꺼기라 잃을 것이 없다 — 원래 막으려던 것도 그 누적이었다(ba90a19).
reuse_existing: bool = True
class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
name: Optional[str] = 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 PlaceSearchItem(WebPacketProtocol):
"""공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""
name: str = ""
road_address: Optional[str] = None
category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션")
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
category: Optional[PlaceCategory] = None
# 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
# 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
naver_place_url: Optional[str] = None
class Res_PlaceSearch(Res_WebPacketProtocol):
"""상호명 공개 검색 결과.
★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 —
만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다."""
source: Optional[ExternalPlaceSource] = None
items: list[PlaceSearchItem] = []
class Req_StartCopy(PlaceProtocol):
"""소개문·FAQ 생성 시작.
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다)."""
resume: bool = False
class Res_StartCopy(Res_WebPacketProtocol):
job_id: Optional[uuid.UUID] = None
status: Optional[JobStatus] = None
created: bool = True
grounded_facts: int = 0 # 근거로 쓸 수 있는 확인된 fact 수