토큰은 받았는데 리디렉션 URI 가 아직 없다고 **게시까지 막혔다**(실측 2026-09-15). `posting_enabled` 가 `accounts.configured()`(앱 ID·시크릿·리디렉션 URI 셋)에 묶여 있었는데, 게시는 그 셋 중 **하나도 쓰지 않는다** — `threads.publish(text, token)` 은 사장님 토큰만 쓴다. 앱 자격증명은 계정을 **새로 연결할 때만** 필요하다. - social_account_service.token_ready(): 토큰을 풀 수 있는가 = 게시의 전제 - posting_enabled(): token_ready + SOCIAL_POSTING_ENABLED. 연결 설정을 묻지 않는다 - 카드 문구: '연결돼 있음' 이 '연결 설정 준비됨' 보다 앞선다. 이미 연결된 계정이 있는데 "준비 중" 이라고 말하면 사장님은 연결이 풀린 줄 알고, 게시는 저장된 토큰만으로 되므로 그 말이 사실도 아니다 - docs/SOCIAL.md: 연결·게시 전제 표 + ★ 리디렉션 URI 는 Meta 가 발급하는 값이 아니라 우리가 정해 등록하는 우리 주소(`https://<SITE_PUBLIC_HOST>/v1/social/oauth/callback`) - .env.example: 같은 설명을 값 옆에 검증: SNS 테스트 14건 통과. 로컬에서 테스트 계정 토큰을 넣어 `account.handle=geumnamsijang · connection_enabled=true · posting_enabled=false`(스위치가 잠긴 상태) 확인 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 lines
11 KiB
Markdown
151 lines
11 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. 사장님이 하는 일 — 연결은 [내 사이트], 게재는 사이트마다
|
||
|
||
**연결(한 번)**: `/sites` **내 사이트** 화면 위의 `SNS 연동 · Threads` 카드 →
|
||
[Threads 계정 연결] → Meta 인가 화면에서 허용 → 돌아오면 카드에 `@핸들` 이 뜬다.
|
||
|
||
**게재(사이트마다)**: 발행한 사이트의 발행 화면 → [소개글 쓰기] → [승인 요청] → 승인.
|
||
|
||
★ **연결 버튼을 사업장 화면에 두지 않는다.** 계정은 `user × provider` 하나인데 버튼이
|
||
사업장 안에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 한 번, 게재는
|
||
사이트마다다 — 화면이 그 모양을 그대로 말해야 한다.
|
||
★ 앱 자격증명이 없으면 이 카드는 **아예 안 그려진다**(`GET /v1/social/account` 의
|
||
`connection_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`** 가 된다. 원인이 화면에 안 보이는 종류라 여기 적어 둔다.
|
||
|
||
### 4. 연결과 게시는 전제가 다르다
|
||
|
||
| | 필요한 것 |
|
||
|---|---|
|
||
| **연결(OAuth)** | `SOCIAL_TOKEN_SECRET` + `THREADS_APP_ID` · `APP_SECRET` · `REDIRECT_URI` |
|
||
| **게시** | `SOCIAL_TOKEN_SECRET`(토큰 복호화) + 저장된 계정 토큰 + `SOCIAL_POSTING_ENABLED=1` |
|
||
|
||
★ 게시는 **이미 받아 둔 사장님 토큰 하나면 된다**(`threads.publish(text, token)`). 앱 자격증명과
|
||
리디렉션 URI 는 계정을 **새로 연결할 때만** 쓴다. 둘을 한 깃발로 묶으면 "리디렉션 URI 가 아직
|
||
없다" 는 이유로 게시까지 막힌다 — 실제로 그랬다(2026-09-15: 토큰은 있고 URI 만 없는데
|
||
게시가 열리지 않았다). 그래서 `accounts.token_ready()` 와 `accounts.configured()` 를 갈랐다.
|
||
|
||
★ 리디렉션 URI 는 **Meta 가 발급하는 값이 아니다.** 우리가 정해서 앱 콘솔에 등록하는 우리
|
||
주소다: `https://<SITE_PUBLIC_HOST>/v1/social/oauth/callback`
|
||
(nginx 가 `/v1/` 을 API 로 보내므로 발행 호스트와 같은 오리진이면 된다).
|
||
|
||
## 보완한 안전장치
|
||
|
||
**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 단위 모든 사업장 연결 해제 |
|