o2o-site-AEO/solution/backend/router/v1/social/oauth.py
hbyang ecafa19d00 [feat] solution/backend,docs: Threads 계정 연동 — 연결 실패 이유를 남기고, 준비 절차를 적는다
자동 게재가 되려면 사장님이 자기 Threads 계정을 연결해야 한다. 연결 코드(인가 URL·코드 교환·
장기 토큰·암호문 저장·해제)는 이미 있었는데, **실제로 연동하려면 무엇을 해야 하는지**가
어디에도 없었고 실패했을 때 이유를 볼 방법도 없었다.

★ 콜백이 예외를 통째로 삼키고 있었다. 화면에는 `?social=failed` 만 뜨고 우리도 원인을 모른다 —
  키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다. 연결이 안 되는데
  로그에 아무것도 없는 것은 이 레포가 가장 싫어하는 종류다.
  → 서버 로그에는 남기고 화면에는 안 내보낸다(OAuth 응답·state 에 자격증명이 들어 있다).
    남기는 것은 예외 종류와 우리가 만든 사유 문자열뿐 — 토큰·code·state 는 찍지 않는다.
    사장님이 인가를 취소한 경우도 고장과 구별되게 따로 남긴다.

- router/v1/social/oauth: 실패 로그 추가(`[social] 계정 연결 실패: …`)
- tests: 가짜 Threads 서버로 **연결 왕복 전체**를 검증한다(코드 교환 → 장기 토큰 → debug_token
  권한 검증 → 저장 → 해제). 실제 연결은 Meta 앱 등록이 끝나야 시험할 수 있는데, 그때 실패하면
  우리 코드가 틀린 건지 앱 설정이 틀린 건지 구별이 안 된다 — 우리 쪽 왕복은 먼저 못 박는다.
  지키는 것 셋: 저장된 것은 암호문이다 · 권한 검증을 건너뛰지 않는다 · 해제하면 토큰이 지워진다
- docs/SOCIAL.md '연동 준비': Meta 앱 콘솔에서 할 일(제품 추가·리디렉션 URI·권한 둘·
  ★심사 전에는 테스터로 추가된 계정만 인가된다) · 사장님 클릭 흐름 · 실패 시 로그 읽는 법
- .env.example: SOCIAL_*·THREADS_*·ALIMTALK_* 항목과 각각의 "비면 무엇이 꺼지는가"

★ 로컬만으로는 연결을 끝까지 검증할 수 없다 — Meta 는 콜백 URI 를 https 로만 받는다.
  터널로 https 주소를 만들거나 킹서버에서 확인해야 한다. 문서에 적어 뒀다.
★ `SOCIAL_TOKEN_SECRET`(Fernet) 이 없으면 연결 기능 자체가 꺼진다. 이 키를 잃으면 저장된
  토큰을 복호화할 수 없어 전원 재연결이다 — 그 사실도 문서에 적었다.

검증: SNS 테스트 14건 통과(신규 1 — 연결 왕복). 로컬에서 키만 넣고 앱 자격증명이 없는 상태를
확인: connection_enabled=false 로 버튼이 안 뜨고, 강제로 불러도 409 SOCIAL_CONNECTION_DISABLED

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

70 lines
2.8 KiB
Python

from uuid import UUID
from fastapi import APIRouter, Depends, Request, Response, HTTPException, Query
from fastapi.responses import RedirectResponse
from common.logger import LOG
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken
from services import social_account_service as service
from services.external.social import SocialError
router = APIRouter(prefix="/v1/social/oauth", tags=["Social"])
COOKIE = "social_oauth_browser"
@router.post("/connect")
async def connect(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
try:
url, browser = service.begin(UUID(user.user_id), 2)
except SocialError as ex:
raise HTTPException(409, str(ex)) from ex
response.set_cookie(
COOKIE,
browser,
httponly=True,
secure=True,
samesite="lax",
max_age=600,
path="/v1/social/oauth",
)
response.headers["Cache-Control"] = "no-store"
return {"url": url}
@router.get("/callback")
async def callback(
request: Request,
state: str = Query("", max_length=2048),
code: str = Query("", max_length=4096),
error: str = Query("", max_length=200),
):
ok = False
if not error and code and state:
try:
await service.finish(state, request.cookies.get(COOKIE), code)
ok = True
except Exception as ex: # noqa: BLE001
# ★ 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다.
# 대신 **서버 로그에는 반드시 남긴다.** 예전에는 통째로 삼켜서, 연결이 안 될 때
# 화면에 `?social=failed` 만 뜨고 우리도 이유를 알 방법이 없었다
# (키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다).
# ★ 남기는 것은 **예외 종류와 우리가 만든 사유 문자열**뿐이다. 토큰·code·state 는 찍지 않는다.
LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}")
elif error:
# 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다.
LOG.i(f"[social] 계정 연결 중단(제공자 응답): {error[:80]}")
response = RedirectResponse(
"/sites?social=" + ("connected" if ok else "failed"), status_code=303
)
response.delete_cookie(
COOKIE, path="/v1/social/oauth", secure=True, httponly=True, samesite="lax"
)
response.headers["Cache-Control"] = "no-store"
response.headers["Referrer-Policy"] = "no-referrer"
return response
@router.post("/disconnect")
async def disconnect(user: UserInfo = Depends(IsValidAccessToken)):
await service.disconnect(UUID(user.user_id), 2)
return {"disconnected": True}