From c8dc68536e34632eee0e7fe2ec7c2f564d4f5665 Mon Sep 17 00:00:00 2001
From: hbyang
Date: Mon, 14 Sep 2026 16:14:11 +0900
Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20SNS=20=EC=97=B0=EB=8F=99?=
=?UTF-8?q?=EC=9D=84=20'=EB=82=B4=20=EC=82=AC=EC=9D=B4=ED=8A=B8'=20?=
=?UTF-8?q?=ED=95=9C=20=EC=9E=90=EB=A6=AC=EB=A1=9C=20=E2=80=94=20=EC=97=B0?=
=?UTF-8?q?=EA=B2=B0=EC=9D=80=20=ED=95=9C=20=EB=B2=88,=20=EA=B2=8C?=
=?UTF-8?q?=EC=9E=AC=EB=8A=94=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EB=A7=88?=
=?UTF-8?q?=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
연결 버튼이 발행 모달 안에 있었다. 그런데 계정은 `user × provider` 하나다(표도 그렇게 생겼다)
— 버튼이 사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 사장님 지적:
"여기 내 사이트에 sns 연동 하나 두고 연동해두고, 사이트 발행 후 게재하는 형식으로".
화면이 데이터 모양을 그대로 말해야 한다 — 연결은 한 번, 게재는 사이트마다다.
- `GET /v1/social/account` 신설: 연결 상태만 준다. 사업장을 고르지 않아도 답할 수 있어야 하는
값인데, 지금까지는 `/place/{id}` 안에만 있어서 사이트를 하나 고르기 전에는 물어볼 수 없었다
- features/social/SocialConnectionCard: '내 사이트' 목록 위의 연동 카드
(@핸들 · [연결] · [다시 연결] · [연결 해제]). 앱 자격증명이 없으면 **아무것도 안 그린다** —
누를 수 없는 버튼을 세우면 사장님에게는 고장난 화면이고 우리에게는 문의가 된다
- SocialPanel(발행 화면)에서 연결·해제 버튼 제거. 대신 계정이 없으면 "막다른 문구"가 아니라
**갈 곳**을 알린다 — [내 사이트] 로 보낸다. 연결 전에도 소개글 복사는 된다
- docs/SOCIAL.md: 사장님 흐름을 '연결(한 번) / 게재(사이트마다)' 로 다시 씀
검증: SNS 테스트 14건 통과 · frontend tsc·eslint 통과. 로컬에서 임시 자격증명으로 카드가
켜지는 것과 인가 URL 조립을 확인하고 값을 되돌렸다(지금은 connection_enabled=false 로 안 뜬다)
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/SOCIAL.md | 16 ++-
solution/backend/router/v1/social/social.py | 7 +
solution/backend/services/social_service.py | 18 +++
.../features/social/SocialConnectionCard.tsx | 121 ++++++++++++++++++
.../src/features/social/SocialPanel.tsx | 16 ++-
solution/frontend/src/pages/SitesPage.tsx | 3 +
6 files changed, 172 insertions(+), 9 deletions(-)
create mode 100644 solution/frontend/src/features/social/SocialConnectionCard.tsx
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(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+
+ const load = useCallback(async () => {
+ try {
+ setState(await socialApi('/account'));
+ } catch {
+ // 연결 상태를 못 읽는 것은 사이트 목록을 못 보여줄 이유가 아니다 — 조용히 접는다.
+ setState(null);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ if (!state?.connection_enabled) return null;
+
+ const account = state.account;
+ const needsReauth = account?.status === 'needs_reauth';
+
+ async function run(fn: () => Promise) {
+ setBusy(true);
+ setError('');
+ try {
+ await fn();
+ await load();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : '요청을 처리하지 못했습니다.');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const connect = () =>
+ run(async () => {
+ const {url} = await socialApi<{url: string}>('/oauth/connect', {});
+ // 인가 화면은 Meta 쪽이다. 돌아오면 `/sites?social=…` 로 되돌아온다.
+ window.location.assign(url);
+ });
+
+ return (
+
+
+
+
SNS 연동 · Threads
+
+ {account
+ ? needsReauth
+ ? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
+ : '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
+ : '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'}
+
+ {/*
+ ★ 연결 버튼을 여기 두지 않는다 (2026-09-14). 계정은 `user × provider` 하나인데 버튼이
+ 사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 [내 사이트]에
+ 한 자리, 게재는 사이트마다다 — 화면이 그 모양을 그대로 말한다.
+ ★ 그래서 여기서는 "연결이 없다" 를 **막다른 문구가 아니라 갈 곳**으로 알린다.
+ */}
+ {state && state.connection_enabled && !state.account && (
+
+ 아직 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() {
}
>
+ {/* 연결은 사업장이 아니라 사람 단위다 — 목록 위에 한 자리만 둔다(SocialConnectionCard 주석). */}
+
{isLoading && (