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 + ? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.' + : '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.' + : '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'} +

+ {account && ( +

+ + @{account.handle} + +

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

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

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/solution/frontend/src/features/social/SocialPanel.tsx b/solution/frontend/src/features/social/SocialPanel.tsx index b8eeb3c..ff90fac 100644 --- a/solution/frontend/src/features/social/SocialPanel.tsx +++ b/solution/frontend/src/features/social/SocialPanel.tsx @@ -31,12 +31,18 @@ export function SocialPanel({placeId}: {placeId: string}) { {state.account.status === 'needs_reauth' && ' · 다시 연결해 주세요'}

}
- {state?.connection_enabled && } - {state?.account && }
+ {/* + ★ 연결 버튼을 여기 두지 않는다 (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 && (