자동 게재가 되려면 사장님이 자기 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>
70 lines
2.8 KiB
Python
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}
|