o2o-site-AEO/docs/SOCIAL.md
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

10 KiB
Raw Blame History

SNS 게재 — Threads

2026-09-14: 사용자 결정으로 X 구현을 제거하고 Threads를 첫 플랫폼으로 선택했다. API 직접 연동에 공개된 건당 요금·유료 티어는 확인되지 않았다. 영구 무료를 보장한다는 뜻은 아니다. Meta 공식 API 컬렉션은 앱 생성·사용자 인가·장기 토큰·텍스트 컨테이너/게시 API를 설명한다. Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권한·최신 한도는 앱 콘솔에서 최종 확인한다.

사용 흐름

발행 모달의 Threads에 알리기 → 소개글 쓰기로 시작한다. 발행에 자동으로 붙지 않는다. 확인된 fact가 없거나, 사이트가 미발행이거나, 확정 domain/current_version_id가 없으면 생성하지 않는다. 본문은 완결된 짧은 문장과 서버가 계산한 발행 URL이다. 500자에는 링크도 포함한다. 문자열은 NFC로 정규화하고 초과하면 최대 3번 다시 요청한다. 잘라서 게시하지 않는다. 같은 사업장·발행 버전은 성공 이후에도 원고 1건만 유지한다. 초안 생성 실패만 같은 행으로 재시도한다.

  • 계약 없이: 초안 작성, 복사, 화면에서 내용 확인/거절, 만료 후 재요청.
  • 실제 연결 이후: Threads 계정 연결 → 게재 승인 요청 → 화면 또는 알림톡 확인 → 명시적 POST 승인 → 게시.
  • 계정 미연결 상태의 내용 확인은 게시를 예약하지 않는다. 연결한 뒤 계정을 보여주고 다시 승인받는다.
  • 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다.
  • 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다. 알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다.
  • 사이트 섹션 social은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다. Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 SNS에 올린 글 보기다.

연동 준비 — 한 번만 하는 일

계정 연결은 두 쪽이 나뉜다. 우리가 한 번 준비하고(앱 등록), 사장님은 버튼 두 번을 누른다.

1. 우리가 한 번 (Meta 앱 콘솔)

  1. 개발자 콘솔에서 앱을 만들고 Threads API 제품을 추가한다. ★ Threads 자격증명은 페이스북·인스타그램 앱의 것과 별개다. Threads 쪽 앱 ID·시크릿을 쓴다.
  2. 리디렉션 콜백 URLhttps://<발행호스트>/v1/social/oauth/callback 을 등록한다. ★ https 여야 한다. http://localhost 는 콜백으로 등록되지 않는다 — 로컬에서 끝까지 돌려보려면 터널(cloudflared·ngrok)로 https 주소를 만들어 그 주소를 등록하거나, https 가 붙어 있는 킹서버에서 확인한다. 이 제약 때문에 연결만은 로컬 단독으로 검증되지 않는다.
  3. 권한은 threads_basic · threads_content_publish 둘이다(services/external/threads.py SCOPES).
  4. 심사 전에는 앱 역할에 추가된 계정만 인가된다. 시험할 사장님 Threads 계정을 테스터로 먼저 추가한다 — 이걸 빼먹으면 인가 화면까지 가서 거절당하고, 화면에는 ?social=failed 만 뜬다.
  5. 루트 .env 에 셋을 채우고 백엔드·워커를 다시 띄운다.
    THREADS_APP_ID=…
    THREADS_APP_SECRET=…
    THREADS_REDIRECT_URI=https://<발행호스트>/v1/social/oauth/callback
    
    SOCIAL_TOKEN_SECRET(Fernet 키)이 없으면 연결 기능 자체가 꺼진다. 평문으로 토큰을 보관하는 길은 만들지 않았다. 만드는 법: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ★ 이 키를 잃어버리면 저장된 토큰을 복호화할 수 없다 — 모든 사장님이 다시 연결해야 한다 (그때 TOKEN_KEY_CHANGEDneeds_reauth 가 된다).

셋 중 하나라도 비면 connection_enabled=false 로 내려가 연결 버튼이 아예 안 뜬다. 버튼을 눌러도 서버는 409 SOCIAL_CONNECTION_DISABLED 로 거절한다 — 반쯤 연결된 상태를 만들지 않는다.

2. 사장님이 하는 일 — 연결은 [내 사이트], 게재는 사이트마다

연결(한 번): /sites 내 사이트 화면 위의 SNS 연동 · Threads 카드 → [Threads 계정 연결] → Meta 인가 화면에서 허용 → 돌아오면 카드에 @핸들 이 뜬다.

게재(사이트마다): 발행한 사이트의 발행 화면 → [소개글 쓰기] → [승인 요청] → 승인.

연결 버튼을 사업장 화면에 두지 않는다. 계정은 user × provider 하나인데 버튼이 사업장 안에 있으면 사장님은 업장마다 연결해야 하는 줄 안다. 연결은 한 번, 게재는 사이트마다다 — 화면이 그 모양을 그대로 말해야 한다. ★ 앱 자격증명이 없으면 이 카드는 아예 안 그려진다(GET /v1/social/accountconnection_enabled). 누를 수 없는 버튼을 세워 두면 사장님에게는 고장난 화면이다.

3. 연결이 안 될 때 — 어디를 보나

콜백은 화면에 이유를 내보내지 않는다(OAuth 응답·state 에 자격증명이 들어 있다). 대신 서버 로그에 남는다:

docker compose logs -f solution-backend | grep "\[social\]"
  [social] 계정 연결 실패: SocialError: INVALID_OAUTH_STATE     ← 쿠키 유실·10분 만료
  [social] 계정 연결 실패: SocialError: THREADS_REJECTED_400    ← 앱 ID/시크릿·리디렉션 URI 불일치
  [social] 계정 연결 중단(제공자 응답): access_denied           ← 사장님이 인가를 취소함

★ 쿠키는 Secure 다. https 가 아닌 호스트(예: 사내 IP 로 직접 접속)에서는 브라우저가 쿠키를 저장하지 않아 항상 INVALID_OAUTH_STATE 가 된다. 원인이 화면에 안 보이는 종류라 여기 적어 둔다.

보완한 안전장치

POSTING 10분 경과는 UNKNOWN이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다. 동일하게 API 성공 이후 DB 커밋 실패도 UNKNOWN으로 남긴다. 사람이 Threads에서 실제 결과를 확인해야 한다. UNKNOWN에는 재게시 버튼이 없다. 플랫폼이 명확히 거절한 FAILED만 새 승인을 받을 수 있다. 게시 ID를 받았으면 permalink 조회 실패에도 POSTED로 기록하고 링크 없이 소식을 보여준다.

승인은 nonce 32바이트의 SHA-256과 PENDING_APPROVAL 조건부 UPDATE를 쓴다. JWT_ACCESS_SECRET·로그인 토큰·사용자 role은 승인 URL에 들어가지 않는다. 승인 전이와 SOCIAL_POST 큐 삽입은 한 트랜잭션이다. GET은 만료 상태를 변경하지 않는다. 화면에서도 시각으로 만료를 표시하므로 스윕 지연이 승인 가능 표시로 이어지지 않는다. 재연결로 account_id가 바뀌면 옛 승인으로 게시할 수 없다. 게시 직전 소유자·사이트 상태·주소·계정을 재검사한다.

계정은 user에 붙는다. 연결 교체·해제·갱신·게시는 user/provider DB 잠금을 공유한다. 토큰은 Fernet 암호문만 저장하고 키가 없거나 형식이 잘못되면 연결하지 않는다. OAuth state도 암호화하고 10분 TTL과 HttpOnly/Secure/SameSite 쿠키로 요청 브라우저에 묶는다. Threads는 X의 offline.access/refresh_token을 쓰지 않는다. 장기 access token 자체를 갱신하며 만료·거절·저장 실패는 재연결 대상으로 처리한다. 자동 주기 갱신은 아직 없으므로 장기 미사용 뒤에는 다시 연결한다. 연결 해제는 보관 토큰을 제거하고 모든 업장의 이후 게시를 막는다. Threads에 이미 쓴 글은 지우지 않는다.

활성화 전 확인

기본 SOCIAL_POSTING_ENABLED=0. 지금 실행하지 않은 외부 작업은 다음과 같다.

  1. Meta 앱 등록, 사용자 계정용 threads_basic·threads_content_publish 권한 심사와 테스트 계정 실게시.
  2. HTTPS OAuth callback과 동일 오리진 쿠키 동작, 보안 키 보관·복원 절차 확인.
  3. DECISIONS 1-4의 해지 안내 페이지 구현/검증. 현재 사이트 상태 전이만으로는 안내 HTML이 재생성되지 않는다. 이 선행조건을 해결하기 전에 운영 자동 게재를 활성화하지 않는다.
  4. 알림톡 대행사 확정·발신프로필·템플릿 심사. 초안 리소스는 services/resources/social_approval.json. 승인 주소는 #{승인주소} 버튼 변수에 연결하고 문구는 심사본과 맞춘다. 아직 심사받은 템플릿이 아니다.
  5. 발신번호·단가·야간 정책·24시간 만료를 운영 정책으로 확정.

nginx/site.conf.example/approve/·/v1/social/ 블록을 실제 설정에도 반영한다. 앞단 프록시도 query string을 기록하지 않아야 한다. 앱 승인 페이지와 API는 no-store/no-referrer다. 마이그레이션 0012/0013 적용 후 빌더/API/워커/프리렌더를 배포한다. 발행 렌더러 변경은 전체 재굽기와 republish_all.py가 필요하며 payload 없는 목업은 대상이 아니다.

API

메서드/경로 역할
GET /v1/social/place/{place_id} 소유자 범위 원고 목록·계정 표시
POST /v1/social/place/{place_id}/draft 초안/잡 원자 생성, 같은 버전 재사용
POST /v1/social/posts/{post_id}/request-approval nonce 발급·계정 고정·선택적 알림톡
POST /v1/social/posts/{post_id}/decision 로그인한 소유자의 화면 승인/거절
GET /v1/social/approval/{post_id}?t=… 무인증 읽기 전용 확인
POST /v1/social/approval/{post_id}/decision {t, approve} 일회성 결정
GET /v1/social/account 연결 상태만 — 사업장을 안 고르고 답한다(내 사이트 카드)
POST /v1/social/oauth/connect Threads 인가 URL·브라우저 쿠키 발급
GET /v1/social/oauth/callback 코드 교환·암호문 보관
POST /v1/social/oauth/disconnect user 단위 모든 사업장 연결 해제