o2o-site-AEO/solution/backend/router/v1/place/place.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

219 lines
9.3 KiB
Python

from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from common.enums import PlaceCategory, PlaceStatus
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from router.v1.job.protocol import Res_Job
from services.place_service import PlaceService
from .protocol import (
Req_CreateLink,
Req_CreatePlace,
Req_CreateUnit,
Req_StartCollect,
Req_StartCopy,
Req_StartVision,
Req_UpdatePlace,
Req_VerifyPlace,
Req_VerifyPlaceByUrl,
Res_Link,
Res_LinkList,
Res_Place,
Res_PlaceList,
Res_StartCollect,
Res_StartCopy,
Res_StartVision,
Res_VerifyCandidates,
Res_Unit,
Res_UnitList,
)
# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다.
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})
@router.get(path="/list", response_model=Res_PlaceList, summary="사업장 목록")
async def list_places(
service: PlaceService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
pg: PageParams = Depends(),
search: Optional[str] = Query(None, description="상호명·주소 부분일치"),
category: Optional[PlaceCategory] = Query(None, description="업종"),
status: Optional[PlaceStatus] = Query(None, description="상태"),
):
return RemoveNoneResponse(await service.list_places(user_info, pg, search, category, status))
@router.post(
path="",
response_model=Res_Place,
summary="사업장 등록",
description="상호명 하나로 시작한다. 주소·좌표는 동일 업소 검증(verify)이 채운다.",
)
async def create_place(req: Req_CreatePlace, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.create_place(user_info, req))
@router.get(path="/{place_id}", response_model=Res_Place, summary="사업장 단건")
async def get_place(place_id: UUID, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_place(user_info, str(place_id)))
@router.patch(path="/{place_id}", response_model=Res_Place, summary="사업장 수정")
async def update_place(
place_id: UUID, req: Req_UpdatePlace, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.update_place(user_info, str(place_id), req))
@router.delete(path="/{place_id}", response_model=Res_Place, summary="사업장 삭제")
async def delete_place(
place_id: UUID, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.delete_place(user_info, str(place_id)))
@router.get(path="/{place_id}/collect/active", response_model=Res_Job, summary="진행 중인 수집 잡")
async def get_active_collect(
place_id: UUID, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.get_active_collect(user_info, str(place_id)))
@router.post(
path="/{place_id}/verify",
response_model=Res_Place,
summary="동일 업소 검증",
description="카카오 로컬 조회 결과를 박제해 동일 업소를 확정한다. ★ 이걸 통과해야 수집이 열린다.",
)
async def verify_place(
place_id: UUID, req: Req_VerifyPlace, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.verify_place(user_info, str(place_id), req))
@router.post(
path="/{place_id}/verify/by-url",
response_model=Res_Place,
summary="네이버 플레이스 URL 로 동일 업소 확정",
description="사장님이 붙여넣은 네이버 플레이스 주소로 상호·주소·좌표를 읽어 확정하고, "
"그 URL 을 수집 채널로 등록·확정한다. "
"★ 상호 검색이 실패하는 가게(동명·지점명 표기 차이)를 위한 확실한 경로다.",
)
async def verify_place_by_url(
place_id: UUID, req: Req_VerifyPlaceByUrl, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.verify_place_by_url(user_info, str(place_id), req))
@router.get(path="/{place_id}/unit/list", response_model=Res_UnitList, summary="하위 단위 목록(객실·메뉴·프로그램)")
async def list_units(place_id: UUID, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_units(user_info, str(place_id)))
@router.post(path="/{place_id}/unit", response_model=Res_Unit, summary="하위 단위 등록")
async def create_unit(
place_id: UUID, req: Req_CreateUnit, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.create_unit(user_info, str(place_id), req))
@router.get(
path="/{place_id}/link/list",
response_model=Res_LinkList,
summary="채널 URL 목록",
description="confirmed_only=true 면 크롤링 대상(확정된 URL)만.",
)
async def list_links(
place_id: UUID,
service: PlaceService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
confirmed_only: bool = Query(False),
):
return RemoveNoneResponse(await service.list_links(user_info, str(place_id), confirmed_only))
@router.post(
path="/{place_id}/link",
response_model=Res_Link,
summary="채널 URL 등록",
description="등록만으로는 크롤링 대상이 되지 않는다 — 확정(confirm)이 따로 필요하다.",
)
async def create_link(
place_id: UUID, req: Req_CreateLink, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.create_link(user_info, str(place_id), req))
@router.post(
path="/{place_id}/link/{link_id}/confirm",
response_model=Res_Link,
summary="채널 URL 확정",
description="★ 확정된 URL 만 크롤링 대상이 된다. 사업장 검증이 끝나야 확정할 수 있다.",
)
async def confirm_link(
place_id: UUID, link_id: UUID, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.confirm_link(user_info, str(place_id), str(link_id)))
@router.post(
path="/{place_id}/collect",
response_model=Res_StartCollect,
summary="수집 시작(비동기)",
description="수집 파이프라인을 큐에 넣고 즉시 job_id 를 돌려준다. 한 건에 몇 분 걸리므로 "
"GET /v1/job/{job_id} 로 진행 상태를 폴링한다. "
"★ 동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다.",
)
async def start_collect(
place_id: UUID, req: Req_StartCollect, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.start_collect(user_info, str(place_id), req))
@router.post(
path="/{place_id}/vision",
response_model=Res_StartVision,
summary="사진 분석 시작(비동기)",
description="Gemini Vision 으로 사진 분류 라벨과 alt 를 생성한다. 수집이 사진을 저장하면 자동으로 걸리며, "
"사장님이 직접 올린 사진이나 재분석(force)에 이 엔드포인트를 쓴다. "
"★ 신뢰도가 낮은 결과는 자동 반영되지 않고 사람 확인 큐에 남는다.",
)
async def start_vision(
place_id: UUID, req: Req_StartVision, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.start_vision(user_info, str(place_id), req))
@router.get(
path="/{place_id}/verify/candidates",
response_model=Res_VerifyCandidates,
summary="동일 업소 후보 조회",
description="외부 장소 DB(카카오 키가 있으면 카카오, 없으면 네이버)에서 후보를 찾아 그대로 돌려준다. "
"★ 서버가 자동 확정하지 않는다 — UI 가 후보를 보여주고 사람이 고른 뒤 POST /verify 로 확정한다. "
"auto_selectable=true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다.",
)
async def verify_candidates(
place_id: UUID,
service: PlaceService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
query: Optional[str] = Query(None, description="검색어. 비우면 등록된 상호명"),
):
return RemoveNoneResponse(await service.find_candidates(user_info, str(place_id), query))
@router.post(
path="/{place_id}/copy",
response_model=Res_StartCopy,
summary="소개문·FAQ 생성(비동기)",
description="확인된 fact 만 근거로 소개문과 FAQ 를 작성한다. "
"★ 근거 없는 수치·시설 언급은 코드로 검증해 반려한다(LLM 은 사실을 만들지 않는다). "
"생성물도 미검증 상태로 들어가 사람이 승인해야 사이트에 나간다.",
)
async def start_copy(
place_id: UUID, req: Req_StartCopy, service: PlaceService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.start_copy(user_info, str(place_id), req))