o2o-site-AEO/solution/backend/router/v1/place/place.py
Mina Choi 94551afdaf [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프
가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.

- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
  body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
  실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
  잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
  순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
  `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
  회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
  스코프가 없으면 아무에게도 안 보이는 유령이다.
  실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
  격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다

남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.

검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:14 +09:00

240 lines
10 KiB
Python

from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
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_PlaceSearch,
Res_StartCollect,
Res_StartCopy,
Res_StartVision,
Res_VerifyCandidates,
Res_Unit,
Res_UnitList,
)
# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_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.get(
path="/search",
response_model=Res_PlaceSearch,
summary="상호명 공개 검색",
description="상호명으로 외부 장소 DB(카카오 → 없으면 네이버)를 찾아 후보를 그대로 돌려준다. "
"★ 인증이 없다 — 랜딩 첫 화면이 부른다. 사업장을 만들지도, 우리 DB 를 읽지도 않는다. "
"★ 응답의 category 는 외부 분류에서 **추정한 기본값**이다. None 이면 못 정한 것이고, "
"값이 있어도 확정이 아니다 — 화면은 언제나 바꿀 수 있게 둔다.",
)
async def search_places_public(
request: Request,
service: PlaceService = Depends(),
q: str = Query(..., min_length=2, max_length=100, description="상호명"),
):
# ★ 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다. FastAPI 는 등록 순서로 매칭해서,
# 뒤에 두면 "search" 가 place_id 로 잡혀 422 가 난다 — 조용히 틀리는 종류다.
client_ip = request.client.host if request.client else "unknown"
return RemoveNoneResponse(await service.search_places_public(q, client_ip))
@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))