[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:
parent
a517439c7d
commit
414c827e38
@ -60,6 +60,9 @@ SOCIAL_APPROVAL_HOURS=24
|
|||||||
SOCIAL_APP_ORIGIN=
|
SOCIAL_APP_ORIGIN=
|
||||||
THREADS_APP_ID=
|
THREADS_APP_ID=
|
||||||
THREADS_APP_SECRET=
|
THREADS_APP_SECRET=
|
||||||
|
# ★ Meta 가 발급하는 값이 아니라 **우리가 정해서 앱 콘솔에 등록**하는 우리 콜백 주소다.
|
||||||
|
# https 여야 하고(로컬 http 는 등록되지 않는다), nginx 가 /v1/ 을 API 로 보내므로
|
||||||
|
# 발행 호스트와 같은 오리진을 쓴다: https://<SITE_PUBLIC_HOST>/v1/social/oauth/callback
|
||||||
THREADS_REDIRECT_URI=
|
THREADS_REDIRECT_URI=
|
||||||
# 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다.
|
# 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다.
|
||||||
# ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다.
|
# ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다.
|
||||||
|
|||||||
@ -81,6 +81,22 @@ docker compose logs -f solution-backend | grep "\[social\]"
|
|||||||
★ 쿠키는 `Secure` 다. https 가 아닌 호스트(예: 사내 IP 로 직접 접속)에서는 브라우저가 쿠키를
|
★ 쿠키는 `Secure` 다. https 가 아닌 호스트(예: 사내 IP 로 직접 접속)에서는 브라우저가 쿠키를
|
||||||
저장하지 않아 **항상 `INVALID_OAUTH_STATE`** 가 된다. 원인이 화면에 안 보이는 종류라 여기 적어 둔다.
|
저장하지 않아 **항상 `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로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.
|
**POSTING 10분 경과는 UNKNOWN**이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.
|
||||||
|
|||||||
@ -23,6 +23,22 @@ def cipher():
|
|||||||
raise SocialError("SOCIAL_CONNECTION_DISABLED") from ex
|
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):
|
def configured(provider=2):
|
||||||
try:
|
try:
|
||||||
cipher()
|
cipher()
|
||||||
|
|||||||
@ -145,8 +145,14 @@ async def account_state(user_id, provider=2):
|
|||||||
|
|
||||||
|
|
||||||
def posting_enabled():
|
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):
|
async def create_draft(user_id, place_id, provider=2):
|
||||||
|
|||||||
@ -70,13 +70,18 @@ export function SocialConnectionCard() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-sm font-bold">SNS 연동 · Threads</h2>
|
<h2 className="text-sm font-bold">SNS 연동 · Threads</h2>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{!ready
|
{/*
|
||||||
? '연동 준비 중입니다. 열리면 여기서 계정을 연결합니다.'
|
★ '연결돼 있음' 이 '연결 설정 준비됨' 보다 앞선다. 이미 연결된 계정이 있는데
|
||||||
: account
|
"준비 중" 이라고 말하면 사장님은 연결이 풀린 줄 안다 — 게시는 저장된 토큰만으로
|
||||||
? needsReauth
|
되므로(services/social_account_service.token_ready) 그 말이 사실도 아니다.
|
||||||
? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
|
*/}
|
||||||
: '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
|
{account
|
||||||
: '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'}
|
? needsReauth
|
||||||
|
? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
|
||||||
|
: '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
|
||||||
|
: ready
|
||||||
|
? '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'
|
||||||
|
: '연동 준비 중입니다. 열리면 여기서 계정을 연결합니다.'}
|
||||||
</p>
|
</p>
|
||||||
{account && (
|
{account && (
|
||||||
<p className="mt-1 text-xs">
|
<p className="mt-1 text-xs">
|
||||||
@ -117,7 +122,7 @@ export function SocialConnectionCard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */}
|
{/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */}
|
||||||
{ready && !state.posting_enabled && (
|
{(ready || account) && !state.posting_enabled && (
|
||||||
<p className="mt-3 text-xs text-muted-foreground">
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
자동 게재는 준비 중입니다. 지금은 소개글을 만들어 복사하거나 내용을 확인할 수 있습니다.
|
자동 게재는 준비 중입니다. 지금은 소개글을 만들어 복사하거나 내용을 확인할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user