diff --git a/.env.example b/.env.example index 78895a1..d7b8e41 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,9 @@ SOCIAL_APPROVAL_HOURS=24 SOCIAL_APP_ORIGIN= THREADS_APP_ID= THREADS_APP_SECRET= +# ★ Meta 가 발급하는 값이 아니라 **우리가 정해서 앱 콘솔에 등록**하는 우리 콜백 주소다. +# https 여야 하고(로컬 http 는 등록되지 않는다), nginx 가 /v1/ 을 API 로 보내므로 +# 발행 호스트와 같은 오리진을 쓴다: https:///v1/social/oauth/callback THREADS_REDIRECT_URI= # 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다. # ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다. diff --git a/docs/SOCIAL.md b/docs/SOCIAL.md index 49cf1e7..57e43c7 100644 --- a/docs/SOCIAL.md +++ b/docs/SOCIAL.md @@ -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:///v1/social/oauth/callback` +(nginx 가 `/v1/` 을 API 로 보내므로 발행 호스트와 같은 오리진이면 된다). + ## 보완한 안전장치 **POSTING 10분 경과는 UNKNOWN**이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다. diff --git a/solution/backend/services/social_account_service.py b/solution/backend/services/social_account_service.py index 179cd2d..01250d2 100644 --- a/solution/backend/services/social_account_service.py +++ b/solution/backend/services/social_account_service.py @@ -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() diff --git a/solution/backend/services/social_service.py b/solution/backend/services/social_service.py index 38010f1..35ccc0c 100644 --- a/solution/backend/services/social_service.py +++ b/solution/backend/services/social_service.py @@ -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): diff --git a/solution/frontend/src/features/social/SocialConnectionCard.tsx b/solution/frontend/src/features/social/SocialConnectionCard.tsx index 832d659..164df83 100644 --- a/solution/frontend/src/features/social/SocialConnectionCard.tsx +++ b/solution/frontend/src/features/social/SocialConnectionCard.tsx @@ -70,13 +70,18 @@ export function SocialConnectionCard() {

SNS 연동 · Threads

- {!ready - ? '연동 준비 중입니다. 열리면 여기서 계정을 연결합니다.' - : account - ? needsReauth - ? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.' - : '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.' - : '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'} + {/* + ★ '연결돼 있음' 이 '연결 설정 준비됨' 보다 앞선다. 이미 연결된 계정이 있는데 + "준비 중" 이라고 말하면 사장님은 연결이 풀린 줄 안다 — 게시는 저장된 토큰만으로 + 되므로(services/social_account_service.token_ready) 그 말이 사실도 아니다. + */} + {account + ? needsReauth + ? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.' + : '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.' + : ready + ? '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.' + : '연동 준비 중입니다. 열리면 여기서 계정을 연결합니다.'}

{account && (

@@ -117,7 +122,7 @@ export function SocialConnectionCard() {

{/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */} - {ready && !state.posting_enabled && ( + {(ready || account) && !state.posting_enabled && (

자동 게재는 준비 중입니다. 지금은 소개글을 만들어 복사하거나 내용을 확인할 수 있습니다.