From 9d306eb68eef05a0cb72b65897ec9709408e00f8 Mon Sep 17 00:00:00 2001 From: jaehwang Date: Mon, 20 Apr 2026 14:41:00 +0900 Subject: [PATCH] =?UTF-8?q?api=201=EC=B0=A8=20=ED=8F=AC=EB=A7=A4=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/__init__.py | 7 ++ app/api/analyses.py | 28 +++++ app/api/channels.py | 21 ++++ app/api/clinics.py | 34 ++++++ app/api/plans.py | 31 +++++ app/api/reports.py | 24 ++++ app/common/__init__.py | 0 app/common/deps.py | 6 + app/main.py | 7 ++ app/models/__init__.py | 0 app/models/analysis.py | 38 ++++++ app/models/channel.py | 12 ++ app/models/clinic.py | 29 +++++ app/models/plan.py | 16 +++ app/models/report.py | 22 ++++ docker-compose.yml | 2 +- docs/API_CONTRACT.md | 261 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 +- 18 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 app/api/__init__.py create mode 100644 app/api/analyses.py create mode 100644 app/api/channels.py create mode 100644 app/api/clinics.py create mode 100644 app/api/plans.py create mode 100644 app/api/reports.py create mode 100644 app/common/__init__.py create mode 100644 app/common/deps.py create mode 100644 app/models/__init__.py create mode 100644 app/models/analysis.py create mode 100644 app/models/channel.py create mode 100644 app/models/clinic.py create mode 100644 app/models/plan.py create mode 100644 app/models/report.py create mode 100644 docs/API_CONTRACT.md diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..d12583e --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,7 @@ +from .clinics import router as clinics_router +from .analyses import router as analyses_router +from .reports import router as reports_router +from .plans import router as plans_router +from .channels import router as channels_router + +routers = [clinics_router, analyses_router, reports_router, plans_router, channels_router] diff --git a/app/api/analyses.py b/app/api/analyses.py new file mode 100644 index 0000000..e1bef1c --- /dev/null +++ b/app/api/analyses.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Depends, status +from common.deps import verify_api_key +from models.analysis import AnalysisCreate, AnalysisStartResponse, AnalysisStatusResponse + +router = APIRouter(prefix="/api/analyses", tags=["analyses"], dependencies=[Depends(verify_api_key)]) + + +@router.post("", status_code=status.HTTP_202_ACCEPTED, response_model=AnalysisStartResponse) +async def start_analysis(body: AnalysisCreate): + return AnalysisStartResponse( + analysis_run_id="22222222-2222-2222-2222-222222222222", + clinic_id=body.clinic_id or "11111111-1111-1111-1111-111111111111", + status="discovering", + estimated_seconds=90, + poll_url="/api/analyses/22222222-2222-2222-2222-222222222222/status", + ) + + +@router.get("/{run_id}/status", response_model=AnalysisStatusResponse) +async def get_analysis_status(run_id: str): + return AnalysisStatusResponse( + analysis_run_id=run_id, + status="collecting", + progress=0.45, + current_step="채널 데이터 수집 중", + channel_errors={}, + completed_at=None, + ) diff --git a/app/api/channels.py b/app/api/channels.py new file mode 100644 index 0000000..cfa7b06 --- /dev/null +++ b/app/api/channels.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends +from common.deps import verify_api_key +from models.channel import ChannelVerifyRequest, ChannelVerifyResponse + +router = APIRouter(prefix="/api/channels", tags=["channels"], dependencies=[Depends(verify_api_key)]) + + +@router.post("/verify", response_model=ChannelVerifyResponse) +async def verify_channels(body: ChannelVerifyRequest): + return ChannelVerifyResponse( + youtube={ + "handle": body.youtube, + "verified": True, + "display_name": "바노바기 BANOBAGI", + "followers": 12345, + } if body.youtube else None, + instagram=[ + {"handle": handle, "verified": "unverifiable", "note": "Instagram 로그인 벽"} + for handle in body.instagram + ] if body.instagram else None, + ) diff --git a/app/api/clinics.py b/app/api/clinics.py new file mode 100644 index 0000000..ee8b436 --- /dev/null +++ b/app/api/clinics.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends, status +from common.deps import verify_api_key +from models.clinic import ClinicCreate, ClinicCreateResponse, ClinicHistoryResponse, RunSummary + +router = APIRouter(prefix="/api/clinics", tags=["clinics"], dependencies=[Depends(verify_api_key)]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=ClinicCreateResponse) +async def create_clinic(body: ClinicCreate): + return ClinicCreateResponse( + id="11111111-1111-1111-1111-111111111111", + url=body.url, + name=body.name, + created_at="2026-04-20T09:00:00Z", + ) + + +@router.get("/{id}/history", response_model=ClinicHistoryResponse) +async def get_clinic_history(id: str): + return ClinicHistoryResponse( + clinic_id=id, + runs=[ + RunSummary( + run_id="22222222-2222-2222-2222-222222222222", + status="complete", + started_at="2026-04-20T09:00:00Z", + completed_at="2026-04-20T09:01:30Z", + overall_score=82, + ) + ], + metrics_timeseries={ + "youtube_subscribers": [{"date": "2026-04-20", "value": 12345}] + }, + ) diff --git a/app/api/plans.py b/app/api/plans.py new file mode 100644 index 0000000..d6e36fd --- /dev/null +++ b/app/api/plans.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, status +from common.deps import verify_api_key +from models.plan import PlanCreate, PlanResponse + +router = APIRouter(prefix="/api/plans", tags=["plans"], dependencies=[Depends(verify_api_key)]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=PlanResponse) +async def create_plan(body: PlanCreate): + return PlanResponse( + id="33333333-3333-3333-3333-333333333333", + analysis_run_id="22222222-2222-2222-2222-222222222222", + brand_guide={}, + channel_strategies=[], + content_strategy={}, + calendar=[], + created_at="2026-04-20T09:10:00Z", + ) + + +@router.get("/{id}", response_model=PlanResponse) +async def get_plan(id: str): + return PlanResponse( + id=id, + analysis_run_id="22222222-2222-2222-2222-222222222222", + brand_guide={}, + channel_strategies=[], + content_strategy={}, + calendar=[], + created_at="2026-04-20T09:10:00Z", + ) diff --git a/app/api/reports.py b/app/api/reports.py new file mode 100644 index 0000000..8ca82bf --- /dev/null +++ b/app/api/reports.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends +from common.deps import verify_api_key +from models.report import ReportResponse, ClinicInfo + +router = APIRouter(prefix="/api/reports", tags=["reports"], dependencies=[Depends(verify_api_key)]) + + +@router.get("/{run_id}", response_model=ReportResponse) +async def get_report(run_id: str): + return ReportResponse( + id=run_id, + clinic=ClinicInfo(name="바노바기성형외과", url="https://www.banobagi.com"), + overall_score=82, + youtube={}, + instagram={}, + facebook={}, + naver_place={}, + naver_blog={}, + gangnam_unni={}, + conversion_strategy={}, + roadmap=[], + kpis=[], + generated_at="2026-04-20T09:01:30Z", + ) diff --git a/app/common/__init__.py b/app/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/deps.py b/app/common/deps.py new file mode 100644 index 0000000..1dfe35b --- /dev/null +++ b/app/common/deps.py @@ -0,0 +1,6 @@ +import os +from fastapi import Header, HTTPException + +async def verify_api_key(x_api_key: str = Header(...)): + if x_api_key != os.getenv("API_KEY"): + raise HTTPException(status_code=401, detail="Invalid API Key") diff --git a/app/main.py b/app/main.py index e12160d..bd42fcb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,14 @@ from fastapi import FastAPI +from dotenv import load_dotenv +from api import routers + +load_dotenv() app = FastAPI() +for router in routers: + app.include_router(router) + @app.get("/health") def health(): diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/analysis.py b/app/models/analysis.py new file mode 100644 index 0000000..f04a79f --- /dev/null +++ b/app/models/analysis.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel + + +class Channels(BaseModel): + youtube: str | None = None + instagram: list[str] | str | None = None + facebook: str | None = None + naver_blog: str | None = None + gangnam_unni: str | None = None + + +class AnalysisOptions(BaseModel): + skip_vision: bool = False + skip_perplexity: bool = False + + +class AnalysisCreate(BaseModel): + clinic_id: str | None = None + url: str | None = None + channels: Channels + options: AnalysisOptions = AnalysisOptions() + + +class AnalysisStartResponse(BaseModel): + analysis_run_id: str + clinic_id: str + status: str + estimated_seconds: int + poll_url: str + + +class AnalysisStatusResponse(BaseModel): + analysis_run_id: str + status: str + progress: float + current_step: str + channel_errors: dict + completed_at: str | None diff --git a/app/models/channel.py b/app/models/channel.py new file mode 100644 index 0000000..093f422 --- /dev/null +++ b/app/models/channel.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel +from typing import Any + + +class ChannelVerifyRequest(BaseModel): + youtube: str | None = None + instagram: list[str] | None = None + + +class ChannelVerifyResponse(BaseModel): + youtube: dict[str, Any] | None = None + instagram: list[dict[str, Any]] | None = None diff --git a/app/models/clinic.py b/app/models/clinic.py new file mode 100644 index 0000000..3c06f14 --- /dev/null +++ b/app/models/clinic.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel + + +class ClinicCreate(BaseModel): + url: str + name: str + name_en: str | None = None + address: str | None = None + + +class ClinicCreateResponse(BaseModel): + id: str + url: str + name: str + created_at: str + + +class RunSummary(BaseModel): + run_id: str + status: str + started_at: str + completed_at: str | None + overall_score: int | None + + +class ClinicHistoryResponse(BaseModel): + clinic_id: str + runs: list[RunSummary] + metrics_timeseries: dict diff --git a/app/models/plan.py b/app/models/plan.py new file mode 100644 index 0000000..724dda9 --- /dev/null +++ b/app/models/plan.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class PlanCreate(BaseModel): + report_id: str + regenerate: bool = False + + +class PlanResponse(BaseModel): + id: str + analysis_run_id: str + brand_guide: dict + channel_strategies: list + content_strategy: dict + calendar: list + created_at: str diff --git a/app/models/report.py b/app/models/report.py new file mode 100644 index 0000000..043e9b6 --- /dev/null +++ b/app/models/report.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + + +class ClinicInfo(BaseModel): + name: str + url: str + + +class ReportResponse(BaseModel): + id: str + clinic: ClinicInfo + overall_score: int + youtube: dict + instagram: dict + facebook: dict + naver_place: dict + naver_blog: dict + gangnam_unni: dict + conversion_strategy: dict + roadmap: list + kpis: list + generated_at: str diff --git a/docker-compose.yml b/docker-compose.yml index 05bdaf4..43e1f14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - container_name: o2o-infinith-api + container_name: o2o-infinith-backend ports: - "8000:8000" volumes: diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md new file mode 100644 index 0000000..e66f0f2 --- /dev/null +++ b/docs/API_CONTRACT.md @@ -0,0 +1,261 @@ +# API_CONTRACT — MVP 엔드포인트 8개 + +**단일 진실 소스.** 프런트(`infinith-web`) ↔ 백엔드(`infinith-api`) 간 계약. + +변경 절차: +1. 이 문서 수정 +2. `app/schemas/*.py` + `app/routers/*.py` 구현 변경 +3. `GET /openapi.json` 결과를 PR 에 diff 로 첨부 +4. 프런트 `npx openapi-typescript` 로 타입 재생성 +5. 양쪽 PR 크로스 리뷰 후 머지 + +--- + +## 공통 사항 + +- **Base URL (dev):** `http://localhost:8000` +- **Base URL (staging/prod):** Railway 배포 후 확정 +- **인증:** 모든 엔드포인트 `X-API-Key: ` 헤더 필수 (MVP) + - 추후 OAuth/JWT 전환 예정 — 현재는 `.env` 의 `API_KEY` 값과 대조 +- **콘텐츠 타입:** `application/json` +- **시간:** 전부 ISO 8601 UTC (예: `2026-04-20T09:00:00Z`) +- **UUID:** 모두 UUID v4 + +--- + +## 1. `POST /api/clinics` — 병원 등록 + +### Request +```json +{ + "url": "https://www.banobagi.com", + "name": "바노바기성형외과", + "name_en": "BANOBAGI", + "address": "서울시 강남구 논현로 842" +} +``` + +### Response `201 Created` +```json +{ + "id": "11111111-1111-1111-1111-111111111111", + "url": "https://www.banobagi.com", + "name": "바노바기성형외과", + "created_at": "2026-04-20T09:00:00Z" +} +``` + +### Error +- `409` — 동일 URL 병원 이미 존재 + +--- + +## 2. `POST /api/analyses` — 분석 시작 (**MVP 핵심**) + +### Request +```json +{ + "clinic_id": "11111111-1111-1111-1111-111111111111", + "channels": { + "youtube": "@banobagi", + "instagram": ["@banobagi_official"], + "facebook": "banobagiofficial", + "naver_blog": "https://blog.naver.com/banobagi", + "gangnam_unni": "https://www.gangnamunni.com/hospital/1234" + }, + "options": { + "skip_vision": false, + "skip_perplexity": false + } +} +``` + +- `clinic_id` 대신 `url` 만 제공 시, 서버가 `clinic_service.upsert_by_url()` 로 자동 생성 +- 채널 필드는 **전부 선택적** — 최소 1개 이상만 제공되면 OK +- `instagram` 은 여러 계정 가능 (예: 본원 + 분원) + +### Response `202 Accepted` +```json +{ + "analysis_run_id": "22222222-2222-2222-2222-222222222222", + "clinic_id": "11111111-1111-1111-1111-111111111111", + "status": "discovering", + "estimated_seconds": 90, + "poll_url": "/api/analyses/22222222-2222-2222-2222-222222222222/status" +} +``` + +### Error +- `404` — `clinic_id` 존재 X +- `422` — 채널 하나도 제공 안됨 + +--- + +## 3. `GET /api/analyses/{run_id}/status` — 진행 상태 폴링 + +### Response `200 OK` +```json +{ + "analysis_run_id": "22222222-2222-2222-2222-222222222222", + "status": "collecting", + "progress": 0.45, + "current_step": "채널 데이터 수집 중", + "channel_errors": { + "gangnam_unni": "로그인 벽으로 스크래핑 실패" + }, + "completed_at": null +} +``` + +### Status lifecycle +``` +pending → discovering → collecting → generating → complete + ↘ partial (일부 채널 실패) + ↘ error (치명적 실패) +``` + +### Frontend 폴링 가이드 +- `status ∈ {complete, partial, error}` 일 때까지 2초 간격 폴링 +- `complete` 또는 `partial` → `GET /api/reports/{run_id}` 로 이동 +- `error` → `channel_errors._pipeline` 에 원인 메시지 + +--- + +## 4. `GET /api/reports/{run_id}` — 리포트 조회 + +### Response `200 OK` + +`src/types/report.ts` 의 `MarketingReport` 타입과 100% 호환 JSON. 프런트는 이 응답을 `transformReport.ts` 에 전달. + +```json +{ + "id": "22222222-...", + "clinic": { + "name": "바노바기성형외과", + "url": "https://www.banobagi.com" + }, + "overall_score": 82, + "youtube": { ... }, + "instagram": { ... }, + "facebook": { ... }, + "naver_place": { ... }, + "naver_blog": { ... }, + "gangnam_unni": { ... }, + "conversion_strategy": { ... }, + "roadmap": [ ... ], + "kpis": [ ... ], + "generated_at": "2026-04-20T09:01:30Z" +} +``` + +### Error +- `404` — run 존재 X +- `409` — 아직 `complete/partial` 아님 (`status: collecting` 등) + +--- + +## 5. `POST /api/plans` — 콘텐츠 기획 생성 + +### Request +```json +{ + "report_id": "22222222-...", + "regenerate": false +} +``` + +- `regenerate=false` (기본) — 기존 플랜 있으면 그대로 반환 +- `regenerate=true` — 강제로 새 플랜 생성 + +### Response `201 Created` (신규) or `200 OK` (기존) +```json +{ + "id": "33333333-...", + "analysis_run_id": "22222222-...", + "brand_guide": { ... }, + "channel_strategies": [ ... ], + "content_strategy": { ... }, + "calendar": [ ... ], + "created_at": "2026-04-20T09:10:00Z" +} +``` + +--- + +## 6. `GET /api/plans/{id}` — 기획 조회 + +`MarketingPlan` 타입 (src/types/plan.ts) 반환. 응답 구조는 5번과 동일. + +--- + +## 7. `GET /api/clinics/{id}/history` — 분석 이력 + +### Response `200 OK` +```json +{ + "clinic_id": "11111111-...", + "runs": [ + { + "run_id": "22222222-...", + "status": "complete", + "started_at": "2026-04-20T09:00:00Z", + "completed_at": "2026-04-20T09:01:30Z", + "overall_score": 82 + } + ], + "metrics_timeseries": { + "youtube_subscribers": [ + {"date": "2026-04-20", "value": 12345}, + {"date": "2026-05-20", "value": 13200} + ] + } +} +``` + +--- + +## 8. `POST /api/channels/verify` — 핸들 실시간 검증 + +입력 폼의 "이 계정이 맞나요?" 버튼용. + +### Request +```json +{ + "youtube": "@banobagi", + "instagram": ["@banobagi_official"] +} +``` + +### Response `200 OK` +```json +{ + "youtube": { + "handle": "@banobagi", + "verified": true, + "display_name": "바노바기 BANOBAGI", + "followers": 12345 + }, + "instagram": [ + { + "handle": "@banobagi_official", + "verified": "unverifiable", + "note": "Instagram 로그인 벽" + } + ] +} +``` + +- `verified: true` — 존재 확인 +- `verified: false` — 404/미존재 +- `verified: "unverifiable"` — 로그인 벽 등으로 확인 불가 (UI는 경고만, 진행 허용) + +--- + +## Out of scope (post-MVP) + +- 결제 / 구독 +- 경쟁사 분석 (자동 추천) +- URL 자동 발견 (홈페이지 크롤링 기반) +- 콘텐츠 스튜디오 API (이미지 생성, 캡션 생성) +- 성과 분석 / 스케줄링 자동 배포 +- 팀/조직/권한 관리 diff --git a/requirements.txt b/requirements.txt index ed79f15..dd0e870 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.136.0 uvicorn[standard]==0.44.0 pydantic==2.13.2 -pydantic-settings==2.13.1 +python-dotenv==1.2.2 redis==7.4.0 httpx==0.28.1 python-jose[cryptography]==3.5.0