[feat] solution: SNS 연동을 '내 사이트' 한 자리로 — 연결은 한 번, 게재는 사이트마다

연결 버튼이 발행 모달 안에 있었다. 그런데 계정은 `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) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-14 16:14:11 +09:00
parent ecafa19d00
commit c8dc68536e
6 changed files with 172 additions and 9 deletions

View File

@ -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 단위 모든 사업장 연결 해제 |

View File

@ -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)

View File

@ -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"

View File

@ -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<SocialState | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try {
setState(await socialApi<SocialState>('/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<unknown>) {
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 (
<section className="mb-4 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-bold">SNS 연동 · Threads</h2>
<p className="mt-1 text-xs text-muted-foreground">
{account
? needsReauth
? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
: '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
: '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'}
</p>
{account && (
<p className="mt-1 text-xs">
<a
href={account.profile_url}
target="_blank"
rel="noopener noreferrer"
className="font-bold underline underline-offset-4"
>
@{account.handle}
</a>
</p>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button size="sm" variant={account && !needsReauth ? 'outline' : 'primary'} disabled={busy} onClick={connect}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Link2 className="size-4" />}
<span>{account ? '다시 연결' : 'Threads 계정 연결'}</span>
</Button>
{account && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => run(() => socialApi('/oauth/disconnect', {}))}
>
<Unlink className="size-4" />
<span>연결 해제</span>
</Button>
)}
</div>
</div>
{/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */}
{!state.posting_enabled && (
<p className="mt-3 text-xs text-muted-foreground">
자동 게재는 준비 중입니다. 지금은 소개글을 만들어 복사하거나 내용을 확인할 수 있습니다.
</p>
)}
{error && (
<p role="alert" className="mt-3 text-xs text-destructive">
{error}
</p>
)}
</section>
);
}

View File

@ -31,12 +31,18 @@ export function SocialPanel({placeId}: {placeId: string}) {
{state.account.status === 'needs_reauth' && ' · 다시 연결해 주세요'}</p>}
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy || !state} onClick={() => void action(() => socialApi(`/place/${placeId}/draft`, {}))}>소개글 쓰기</Button>
{state?.connection_enabled && <Button size="sm" variant="outline" disabled={busy} onClick={() => void action(async () => {
const result = await socialApi<{url: string}>('/oauth/connect', {});
window.location.assign(result.url);
})}>{state.account ? 'Threads 다시 연결' : 'Threads 계정 연결'}</Button>}
{state?.account && <Button size="sm" variant="ghost" disabled={busy} onClick={() => void action(() => socialApi('/oauth/disconnect', {}))}>연결 해제</Button>}
</div>
{/*
★ 연결 버튼을 여기 두지 않는다 (2026-09-14). 계정은 `user × provider` 하나인데 버튼이
사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 [내 사이트]에
한 자리, 게재는 사이트마다다 — 화면이 그 모양을 그대로 말한다.
★ 그래서 여기서는 "연결이 없다" 를 **막다른 문구가 아니라 갈 곳**으로 알린다.
*/}
{state && state.connection_enabled && !state.account && (
<p className="text-xs text-muted-foreground">
아직 Threads 계정이 연결되지 않았습니다 — <a href="/sites" className="underline underline-offset-4">내 사이트</a> 화면에서 한 번만 연결하면 됩니다. 연결 전에도 소개글을 만들어 복사할 수 있습니다.
</p>
)}
{state && !state.posting_enabled && <p className="text-xs text-muted-foreground">자동 게재 연동을 준비 중입니다. 글 복사와 내용 확인을 이용할 수 있습니다. 지금 확인해도 계정 연결 후 다시 승인받습니다.</p>}
<p role="status" aria-live="polite">{message}</p>
{state?.posts.map((post) => {

View File

@ -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() {
}
>
<SocialConnectionNotice />
{/* 연결은 사업장이 아니라 사람 단위다 — 목록 위에 한 자리만 둔다(SocialConnectionCard 주석). */}
<SocialConnectionCard />
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />