[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:
parent
ecafa19d00
commit
c8dc68536e
@ -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 단위 모든 사업장 연결 해제 |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
121
solution/frontend/src/features/social/SocialConnectionCard.tsx
Normal file
121
solution/frontend/src/features/social/SocialConnectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user