Compare commits

...

2 Commits

4 changed files with 114 additions and 0 deletions

View File

@ -30,6 +30,22 @@ async def account(response: Response, user: UserInfo = Depends(IsValidAccessToke
return await service.account_state(UUID(user.user_id)) 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}") @router.get("/place/{place_id}")
async def list_posts( async def list_posts(
place_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken) place_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)

View File

@ -149,6 +149,26 @@ def posting_enabled():
return accounts.configured() and config.get("SOCIAL_POSTING_ENABLED") == "1" 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 create_draft(user_id, place_id, provider=2):
async def run(s): async def run(s):
place, site, url = await target(s, user_id, place_id) place, site, url = await target(s, user_id, place_id)

View File

@ -23,6 +23,11 @@ export function SocialConnectionCard() {
const [state, setState] = useState<SocialState | null>(null); const [state, setState] = useState<SocialState | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); 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<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -64,6 +69,25 @@ export function SocialConnectionCard() {
window.location.assign(url); 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 ( return (
<section className="mb-4 rounded-xl border border-border p-4"> <section className="mb-4 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@ -116,6 +140,58 @@ export function SocialConnectionCard() {
</div> </div>
</div> </div>
{account && !needsReauth && (
<div className="mt-3 border-t border-border pt-3">
<label htmlFor="social-test-text" className="text-xs font-bold">
</label>
<div className="mt-1 flex gap-2">
<input
id="social-test-text"
type="text"
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder="이 계정으로 바로 올라갑니다"
maxLength={500}
className="min-w-0 flex-1 rounded-md border border-border px-2 py-1 text-xs"
/>
<Button
size="sm"
variant="outline"
disabled={testBusy || !testText.trim()}
onClick={testPost}
>
{testBusy ? <Loader2 className="size-4 animate-spin" /> : null}
<span> </span>
</Button>
</div>
{testPosted && (
<p className="mt-1 text-xs">
{testPermalink ? (
<>
:{' '}
<a
href={testPermalink}
target="_blank"
rel="noopener noreferrer"
className="font-bold underline underline-offset-4"
>
</a>
</>
) : (
'게시됐습니다. 잠시 후 계정에서 직접 확인해 주세요.'
)}
</p>
)}
{testError && (
<p role="alert" className="mt-1 text-xs text-destructive">
{testError}
</p>
)}
</div>
)}
{/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */} {/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */}
{ready && !state.posting_enabled && ( {ready && !state.posting_enabled && (
<p className="mt-3 text-xs text-muted-foreground"> <p className="mt-3 text-xs text-muted-foreground">

View File

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