자동 게재가 되려면 사장님이 자기 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>
127 lines
9.5 KiB
Markdown
127 lines
9.5 KiB
Markdown
# SNS 게재 — Threads
|
||
|
||
2026-09-14: 사용자 결정으로 X 구현을 제거하고 Threads를 첫 플랫폼으로 선택했다.
|
||
API 직접 연동에 공개된 건당 요금·유료 티어는 확인되지 않았다. 영구 무료를 보장한다는 뜻은 아니다.
|
||
[Meta 공식 API 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-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. **리디렉션 콜백 URL** 에 `https://<발행호스트>/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_CHANGED` 로 `needs_reauth` 가 된다).
|
||
|
||
셋 중 하나라도 비면 `connection_enabled=false` 로 내려가 **연결 버튼이 아예 안 뜬다.**
|
||
버튼을 눌러도 서버는 `409 SOCIAL_CONNECTION_DISABLED` 로 거절한다 — 반쯤 연결된 상태를 만들지 않는다.
|
||
|
||
### 2. 사장님이 하는 일
|
||
|
||
발행 완료 화면 → **[Threads 계정 연결]** → Meta 인가 화면에서 허용 → 돌아오면 패널에 `@핸들` 이 뜬다.
|
||
그 뒤로는 **[소개글 쓰기] → [승인 요청] → 승인** 이 끝이다. 연결은 사람당 한 번이고
|
||
(`user_id × provider` 활성 1건), 업장을 여러 개 가져도 계정은 하나다.
|
||
|
||
### 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}` 일회성 결정 |
|
||
| POST /v1/social/oauth/connect | Threads 인가 URL·브라우저 쿠키 발급 |
|
||
| GET /v1/social/oauth/callback | 코드 교환·암호문 보관 |
|
||
| POST /v1/social/oauth/disconnect | user 단위 모든 사업장 연결 해제 |
|