[fix] solution,docs: 게시가 리디렉션 URI 를 기다리던 것 — 연결과 게시의 전제를 가른다

토큰은 받았는데 리디렉션 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>
This commit is contained in:
hbyang 2026-09-15 17:32:52 +09:00
parent a517439c7d
commit 414c827e38
5 changed files with 56 additions and 10 deletions

View File

@ -60,6 +60,9 @@ SOCIAL_APPROVAL_HOURS=24
SOCIAL_APP_ORIGIN=
THREADS_APP_ID=
THREADS_APP_SECRET=
# ★ Meta 가 발급하는 값이 아니라 **우리가 정해서 앱 콘솔에 등록**하는 우리 콜백 주소다.
# https 여야 하고(로컬 http 는 등록되지 않는다), nginx 가 /v1/ 을 API 로 보내므로
# 발행 호스트와 같은 오리진을 쓴다: https://<SITE_PUBLIC_HOST>/v1/social/oauth/callback
THREADS_REDIRECT_URI=
# 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다.
# ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다.

View File

@ -81,6 +81,22 @@ docker compose logs -f solution-backend | grep "\[social\]"
★ 쿠키는 `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로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.

View File

@ -23,6 +23,22 @@ def cipher():
raise SocialError("SOCIAL_CONNECTION_DISABLED") from ex
def token_ready():
"""저장된 토큰을 풀 수 있는가 — **게시의 전제**다.
연결(OAuth) 전제가 다르다. 연결은 자격증명 (ID·시크릿·리디렉션 URI) 있어야
시작할 있지만, **게시는 이미 받아 사장님 토큰 하나면 된다**
(`threads.publish(text, token)` 토큰만 쓴다).
둘을 깃발로 묶으면 리디렉션 URI 아직 없다는 이유로 **게시까지 막힌다**
실제로 그랬다(2026-09-15: 토큰은 있고 URI 없는데 게시가 열리지 않았다).
"""
try:
cipher()
return True
except SocialError:
return False
def configured(provider=2):
try:
cipher()

View File

@ -145,8 +145,14 @@ async def account_state(user_id, provider=2):
def posting_enabled():
# 플랫폼 계약과 해지 안내 페이지 정책을 운영에서 확인한 뒤 명시적으로 연다.
return accounts.configured() and config.get("SOCIAL_POSTING_ENABLED") == "1"
"""게시가 열려 있는가.
연결(OAuth) 설정을 묻지 않는다 게시에 필요한 것은 ** 있는 토큰** 스위치뿐이다.
자격증명·리디렉션 URI 계정을 새로 연결할 때만 쓴다(`accounts.token_ready` 주석).
스위치는 사람이 연다. 플랫폼 계약과 해지 안내 페이지 정책(DECISIONS 1-4) 확인한 뒤다
게시는 되돌릴 없다.
"""
return accounts.token_ready() and config.get("SOCIAL_POSTING_ENABLED") == "1"
async def create_draft(user_id, place_id, provider=2):

View File

@ -70,13 +70,18 @@ export function SocialConnectionCard() {
<div className="min-w-0">
<h2 className="text-sm font-bold">SNS · Threads</h2>
<p className="mt-1 text-xs text-muted-foreground">
{!ready
? '연동 준비 중입니다. 열리면 여기서 계정을 연결합니다.'
: account
? needsReauth
? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
: '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
: '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'}
{/*
'연결돼 있음' '연결 설정 준비됨' .
"준비 중"
(services/social_account_service.token_ready) .
*/}
{account
? needsReauth
? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
: '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
: ready
? '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'
: '연동 준비 중입니다. 열리면 여기서 계정을 연결합니다.'}
</p>
{account && (
<p className="mt-1 text-xs">
@ -117,7 +122,7 @@ export function SocialConnectionCard() {
</div>
{/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */}
{ready && !state.posting_enabled && (
{(ready || account) && !state.posting_enabled && (
<p className="mt-3 text-xs text-muted-foreground">
. .
</p>