diff --git a/docs/SOCIAL.md b/docs/SOCIAL.md
index 10452d7..49cf1e7 100644
--- a/docs/SOCIAL.md
+++ b/docs/SOCIAL.md
@@ -53,11 +53,18 @@ Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권
셋 중 하나라도 비면 `connection_enabled=false` 로 내려가 **연결 버튼이 아예 안 뜬다.**
버튼을 눌러도 서버는 `409 SOCIAL_CONNECTION_DISABLED` 로 거절한다 — 반쯤 연결된 상태를 만들지 않는다.
-### 2. 사장님이 하는 일
+### 2. 사장님이 하는 일 — 연결은 [내 사이트], 게재는 사이트마다
-발행 완료 화면 → **[Threads 계정 연결]** → Meta 인가 화면에서 허용 → 돌아오면 패널에 `@핸들` 이 뜬다.
-그 뒤로는 **[소개글 쓰기] → [승인 요청] → 승인** 이 끝이다. 연결은 사람당 한 번이고
-(`user_id × provider` 활성 1건), 업장을 여러 개 가져도 계정은 하나다.
+**연결(한 번)**: `/sites` **내 사이트** 화면 위의 `SNS 연동 · Threads` 카드 →
+[Threads 계정 연결] → Meta 인가 화면에서 허용 → 돌아오면 카드에 `@핸들` 이 뜬다.
+
+**게재(사이트마다)**: 발행한 사이트의 발행 화면 → [소개글 쓰기] → [승인 요청] → 승인.
+
+★ **연결 버튼을 사업장 화면에 두지 않는다.** 계정은 `user × provider` 하나인데 버튼이
+ 사업장 안에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 한 번, 게재는
+ 사이트마다다 — 화면이 그 모양을 그대로 말해야 한다.
+★ 앱 자격증명이 없으면 이 카드는 **아예 안 그려진다**(`GET /v1/social/account` 의
+ `connection_enabled`). 누를 수 없는 버튼을 세워 두면 사장님에게는 고장난 화면이다.
### 3. 연결이 안 될 때 — 어디를 보나
@@ -121,6 +128,7 @@ Threads는 X의 offline.access/refresh_token을 쓰지 않는다. 장기 access
| 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 단위 모든 사업장 연결 해제 |
diff --git a/solution/backend/router/v1/social/social.py b/solution/backend/router/v1/social/social.py
index 3c3e256..74ea56c 100644
--- a/solution/backend/router/v1/social/social.py
+++ b/solution/backend/router/v1/social/social.py
@@ -23,6 +23,13 @@ def private_response(response: Response):
response.headers["X-Robots-Tag"] = "noindex, nofollow"
+@router.get("/account")
+async def account(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
+ """연결 상태만 준다 — 사업장을 고르지 않아도 답할 수 있어야 하는 값이다."""
+ private_response(response)
+ return await service.account_state(UUID(user.user_id))
+
+
@router.get("/place/{place_id}")
async def list_posts(
place_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)
diff --git a/solution/backend/services/social_service.py b/solution/backend/services/social_service.py
index 329d210..38010f1 100644
--- a/solution/backend/services/social_service.py
+++ b/solution/backend/services/social_service.py
@@ -126,6 +126,24 @@ async def list_posts(user_id, place_id):
return await db.transaction(run)
+async def account_state(user_id, provider=2):
+ """사장님의 SNS 계정 연결 상태. **사업장과 무관하다.**
+
+ ★ 왜 place 경로와 따로 두나 — 계정은 `user × provider` 단위라(표도 그렇게 생겼다)
+ 사이트마다 물어볼 값이 아니다. 연결 화면이 사업장 안에 있으면 사장님은 업장 수만큼
+ 연결해야 하는 줄 안다. 연결은 한 번, 게재는 사이트마다다.
+ """
+ async def run(s):
+ row = await accounts.account(s, user_id, provider)
+ return {
+ "connection_enabled": accounts.configured(provider),
+ "posting_enabled": posting_enabled(),
+ "account": ({"handle": row.handle, "profile_url": row.profile_url,
+ "status": row.status} if row else None),
+ }
+ return await db.transaction(run)
+
+
def posting_enabled():
# 플랫폼 계약과 해지 안내 페이지 정책을 운영에서 확인한 뒤 명시적으로 연다.
return accounts.configured() and config.get("SOCIAL_POSTING_ENABLED") == "1"
diff --git a/solution/frontend/src/features/social/SocialConnectionCard.tsx b/solution/frontend/src/features/social/SocialConnectionCard.tsx
new file mode 100644
index 0000000..41d6959
--- /dev/null
+++ b/solution/frontend/src/features/social/SocialConnectionCard.tsx
@@ -0,0 +1,121 @@
+import {useCallback, useEffect, useState} from 'react';
+import {Loader2, Link2, Unlink} from 'lucide-react';
+import {Button} from '@/components/ui/button';
+import {socialApi, type SocialState} from './api';
+
+/**
+ * Threads 계정 연동 — **'내 사이트' 화면에 한 자리**.
+ *
+ * ★ 왜 발행 모달이 아니라 여기인가
+ * 계정은 `user × provider` 단위다(표도 그렇게 생겼다 — `owner_social_accounts`).
+ * 연결 버튼이 사업장 안에 있으면 사장님은 **업장 수만큼 연결해야 하는 줄 안다.**
+ * 연결은 한 번, 게재는 사이트마다다 — 화면이 그 모양을 그대로 말해야 한다.
+ * 그래서 여기서 미리 연결해 두고, 발행한 뒤 사이트에서 [소개글 쓰기] 를 누른다.
+ *
+ * ★ 연결이 꺼져 있으면 **아무것도 그리지 않는다.**
+ * `THREADS_APP_ID` 같은 앱 자격증명이 없으면 눌러도 409 다(`SOCIAL_CONNECTION_DISABLED`).
+ * 누를 수 없는 버튼을 세워 두면 사장님에게는 고장난 화면이고, 우리에게는 문의가 된다.
+ * 준비되면 서버가 `connection_enabled` 로 알려 준다(docs/SOCIAL.md '연동 준비').
+ */
+export function SocialConnectionCard() {
+ const [state, setState] = useState
+ {account
+ ? needsReauth
+ ? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
+ : '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
+ : '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'}
+
+ 자동 게재는 준비 중입니다. 지금은 소개글을 만들어 복사하거나 내용을 확인할 수 있습니다.
+
+ {error}
+ SNS 연동 · Threads
+
+ 아직 Threads 계정이 연결되지 않았습니다 — 내 사이트 화면에서 한 번만 연결하면 됩니다. 연결 전에도 소개글을 만들어 복사할 수 있습니다. +
+ )} {state && !state.posting_enabled &&자동 게재 연동을 준비 중입니다. 글 복사와 내용 확인을 이용할 수 있습니다. 지금 확인해도 계정 연결 후 다시 승인받습니다.
}{message}
{state?.posts.map((post) => { diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index e6aea50..fe461dc 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -1,3 +1,4 @@ +import {SocialConnectionCard} from '@/features/social/SocialConnectionCard'; import {SocialConnectionNotice} from '@/features/social/SocialConnectionNotice'; import {useMemo, useState} from 'react'; import {Link, useNavigate} from 'react-router'; @@ -254,6 +255,8 @@ export function SitesPage() { } >