o2o-site-AEO/solution/backend/router/v1/social/social.py
hbyang c8dc68536e [feat] solution: SNS 연동을 '내 사이트' 한 자리로 — 연결은 한 번, 게재는 사이트마다
연결 버튼이 발행 모달 안에 있었다. 그런데 계정은 `user × provider` 하나다(표도 그렇게 생겼다)
— 버튼이 사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 사장님 지적:
"여기 내 사이트에 sns 연동 하나 두고 연동해두고, 사이트 발행 후 게재하는 형식으로".
화면이 데이터 모양을 그대로 말해야 한다 — 연결은 한 번, 게재는 사이트마다다.

- `GET /v1/social/account` 신설: 연결 상태만 준다. 사업장을 고르지 않아도 답할 수 있어야 하는
  값인데, 지금까지는 `/place/{id}` 안에만 있어서 사이트를 하나 고르기 전에는 물어볼 수 없었다
- features/social/SocialConnectionCard: '내 사이트' 목록 위의 연동 카드
  (@핸들 · [연결] · [다시 연결] · [연결 해제]). 앱 자격증명이 없으면 **아무것도 안 그린다** —
  누를 수 없는 버튼을 세우면 사장님에게는 고장난 화면이고 우리에게는 문의가 된다
- SocialPanel(발행 화면)에서 연결·해제 버튼 제거. 대신 계정이 없으면 "막다른 문구"가 아니라
  **갈 곳**을 알린다 — [내 사이트] 로 보낸다. 연결 전에도 소개글 복사는 된다
- docs/SOCIAL.md: 사장님 흐름을 '연결(한 번) / 게재(사이트마다)' 로 다시 씀

검증: SNS 테스트 14건 통과 · frontend tsc·eslint 통과. 로컬에서 임시 자격증명으로 카드가
켜지는 것과 인가 URL 조립을 확인하고 값을 되돌렸다(지금은 connection_enabled=false 로 안 뜬다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:14:11 +09:00

85 lines
2.6 KiB
Python

from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken
from services import social_service as service
from services.external.social import SocialError
router = APIRouter(prefix="/v1/social", tags=["Social"])
class Decision(BaseModel):
approve: bool
class LinkDecision(Decision):
t: str = Field(min_length=40, max_length=100)
def private_response(response: Response):
response.headers["Cache-Control"] = "no-store"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Robots-Tag"] = "noindex, nofollow"
@router.get("/account")
async def account(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
"""연결 상태만 준다 — 사업장을 고르지 않아도 답할 수 있어야 하는 값이다."""
private_response(response)
return await service.account_state(UUID(user.user_id))
@router.get("/place/{place_id}")
async def list_posts(
place_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)
):
private_response(response)
return await service.list_posts(UUID(user.user_id), place_id)
@router.post("/place/{place_id}/draft")
async def draft(
place_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)
):
private_response(response)
return await service.create_draft(UUID(user.user_id), place_id)
@router.post("/posts/{post_id}/request-approval")
async def request_approval(
post_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)
):
private_response(response)
try:
return await service.request_approval(UUID(user.user_id), post_id)
except (SocialError, RuntimeError) as ex:
raise HTTPException(
502, "APPROVAL_NOTIFICATION_FAILED_SCREEN_AVAILABLE"
) from ex
@router.post("/posts/{post_id}/decision")
async def owner_decision(
post_id: UUID,
req: Decision,
response: Response,
user: UserInfo = Depends(IsValidAccessToken),
):
private_response(response)
return await service.owner_decision(UUID(user.user_id), post_id, req.approve)
@router.get("/approval/{post_id}")
async def approval(
post_id: UUID, response: Response, t: str = Query(min_length=40, max_length=100)
):
private_response(response)
return await service.approval(post_id, t)
@router.post("/approval/{post_id}/decision")
async def decision(post_id: UUID, req: LinkDecision, response: Response):
private_response(response)
return await service.approval(post_id, req.t, approve=req.approve)