From 921c85df259edf90e94e76234c5d0dac73fe99de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 18 Sep 2026 13:00:03 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20SNS=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=EC=9A=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- solution/backend/router/v1/social/social.py | 16 ++++ solution/backend/services/social_service.py | 20 +++++ .../features/social/SocialConnectionCard.tsx | 76 +++++++++++++++++++ solution/frontend/src/features/social/api.ts | 2 + 4 files changed, 114 insertions(+) diff --git a/solution/backend/router/v1/social/social.py b/solution/backend/router/v1/social/social.py index 74ea56c..db93abe 100644 --- a/solution/backend/router/v1/social/social.py +++ b/solution/backend/router/v1/social/social.py @@ -30,6 +30,22 @@ async def account(response: Response, user: UserInfo = Depends(IsValidAccessToke return await service.account_state(UUID(user.user_id)) +class TestPost(BaseModel): + text: str = Field(min_length=1, max_length=500) + + +@router.post("/test-post") +async def test_post( + req: TestPost, response: Response, user: UserInfo = Depends(IsValidAccessToken) +): + """연동 확인용 즉시 게시 — 승인 없이 바로 연결된 계정으로 올라간다.""" + private_response(response) + try: + return await service.test_post(UUID(user.user_id), req.text) + except SocialError as ex: + raise HTTPException(409, str(ex)) from ex + + @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 38010f1..a5281e6 100644 --- a/solution/backend/services/social_service.py +++ b/solution/backend/services/social_service.py @@ -149,6 +149,26 @@ def posting_enabled(): return accounts.configured() and config.get("SOCIAL_POSTING_ENABLED") == "1" +async def test_post(user_id, text, provider=2): + """사장님이 자기 계정 연결이 실제로 되는지 확인하려고 한 줄 올려 보는 것. + + ★ 왜 posting_enabled()·승인 절차를 거치지 않는가 — 그 게이트는 승인 기반 + "소식 발행" 상품을 계약·해지 안내 정책이 끝나기 전에 열지 않으려는 것이다. + 여기서 사장님이 자기 계정에 테스트 한 줄을 올리는 것은 그 상품과 무관하다. + """ + + async def run(s): + await accounts.lock_user(s, user_id, provider) + row = await accounts.account(s, user_id, provider) + if not row: + raise SocialError("ACCOUNT_CONNECTION_REQUIRED") + async with httpx.AsyncClient(timeout=20) as client: + token = await accounts.get_usable_token(s, row, client) + return await adapter(provider).publish(text, token, client=client) + + return await db.transaction(run) + + async def create_draft(user_id, place_id, provider=2): async def run(s): place, site, url = await target(s, user_id, place_id) diff --git a/solution/frontend/src/features/social/SocialConnectionCard.tsx b/solution/frontend/src/features/social/SocialConnectionCard.tsx index 832d659..16bf683 100644 --- a/solution/frontend/src/features/social/SocialConnectionCard.tsx +++ b/solution/frontend/src/features/social/SocialConnectionCard.tsx @@ -23,6 +23,11 @@ export function SocialConnectionCard() { const [state, setState] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const [testText, setTestText] = useState(''); + const [testBusy, setTestBusy] = useState(false); + const [testError, setTestError] = useState(''); + const [testPosted, setTestPosted] = useState(false); + const [testPermalink, setTestPermalink] = useState(null); const load = useCallback(async () => { try { @@ -64,6 +69,25 @@ export function SocialConnectionCard() { window.location.assign(url); }); + async function testPost() { + setTestBusy(true); + setTestError(''); + setTestPosted(false); + setTestPermalink(null); + try { + const result = await socialApi<{id: string; permalink: string | null}>('/test-post', { + text: testText, + }); + setTestPosted(true); + setTestPermalink(result.permalink); + setTestText(''); + } catch (e) { + setTestError(e instanceof Error ? e.message : '요청을 처리하지 못했습니다.'); + } finally { + setTestBusy(false); + } + } + return (
@@ -116,6 +140,58 @@ export function SocialConnectionCard() {
+ {account && !needsReauth && ( +
+ +
+ setTestText(e.target.value)} + placeholder="이 계정으로 바로 올라갑니다" + maxLength={500} + className="min-w-0 flex-1 rounded-md border border-border px-2 py-1 text-xs" + /> + +
+ {testPosted && ( +

+ {testPermalink ? ( + <> + 게시됐습니다:{' '} + + 확인하기 → + + + ) : ( + '게시됐습니다. 잠시 후 계정에서 직접 확인해 주세요.' + )} +

+ )} + {testError && ( +

+ {testError} +

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

diff --git a/solution/frontend/src/features/social/api.ts b/solution/frontend/src/features/social/api.ts index 844c14c..784a48b 100644 --- a/solution/frontend/src/features/social/api.ts +++ b/solution/frontend/src/features/social/api.ts @@ -19,6 +19,8 @@ const messages: Record = { PUBLISH_URL_CHANGED: '홈페이지 주소가 변경되었습니다. 기존 승인으로 게재할 수 없습니다.', APPROVAL_NOT_FOUND: '유효하지 않거나 교체된 승인 링크입니다.', PLACE_NOT_FOUND: '가게를 찾을 수 없습니다.', + ACCOUNT_NEEDS_REAUTH: '연결이 만료됐습니다. 다시 연결해 주세요.', + TEXT_TOO_LONG: '글이 너무 깁니다.', }; export async function socialApi(path: string, data?: unknown, publicAccess = false): Promise { const token = publicAccess ? null : getAccessToken();