o2o-site-AEO/solution/backend/services/social_account_service.py
hbyang 0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00

181 lines
6.0 KiB
Python

"""위임 토큰은 암호문만 저장한다. 갱신·연결 해제·게시가 같은 계정 잠금을 사용한다."""
import json
from config import social_config as config
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from uuid import UUID
import httpx
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy import select, update, text
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import owner_social_accounts as Account
from services.external.social import adapter, SocialError
def cipher():
try:
return Fernet(config.required("SOCIAL_TOKEN_SECRET").encode())
except (KeyError, ValueError) as ex:
raise SocialError("SOCIAL_CONNECTION_DISABLED") from ex
def configured(provider=2):
try:
cipher()
return adapter(provider).is_configured()
except SocialError:
return False
def encrypt(value):
return cipher().encrypt(value.encode()).decode()
def decrypt(value):
try:
return cipher().decrypt(value.encode()).decode()
except (InvalidToken, AttributeError) as ex:
raise SocialError("TOKEN_KEY_CHANGED", reauth=True) from ex
def begin(user_id, provider):
if not configured(provider):
raise SocialError("SOCIAL_CONNECTION_DISABLED")
browser = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(32)
# 서명만 된 state는 PKCE verifier를 URL로 공개한다. 암호화하고 브라우저 쿠키에도 묶는다.
state = encrypt(
json.dumps(
{
"u": str(user_id),
"p": provider,
"v": verifier,
"b": hashlib.sha256(browser.encode()).hexdigest(),
}
)
)
return adapter(provider).authorize_url(state, verifier), browser
async def finish(state, browser, code):
try:
data = json.loads(cipher().decrypt(state.encode(), ttl=600))
if not browser or not secrets.compare_digest(
data["b"], hashlib.sha256(browser.encode()).hexdigest()
):
raise ValueError()
user_id = UUID(data["u"])
provider = int(data["p"])
except (InvalidToken, ValueError, KeyError, TypeError) as ex:
raise SocialError("INVALID_OAUTH_STATE") from ex
async with httpx.AsyncClient(timeout=20) as client:
token = await adapter(provider).exchange(code, data["v"], client=client)
profile = await adapter(provider).me(token["access_token"], client=client)
async def save(s):
# 업장별 연결 해제로 다른 업장의 토큰이 살아남지 않도록 사람+플랫폼 단위로 잠근다.
await lock_user(s, user_id, provider)
await s.execute(
update(Account)
.where(
Account.user_id == user_id,
Account.provider == provider,
Account.deleted == False,
)
.values(status="revoked", access_token=None, refresh_token=None)
) # noqa: E712
s.add(
Account(
user_id=user_id,
provider=provider,
provider_user_id=profile["id"],
handle=profile["handle"],
profile_url=profile["profile_url"],
access_token=encrypt(token["access_token"]),
refresh_token=encrypt(token["refresh_token"])
if token.get("refresh_token")
else None,
scopes=token["scope"].split(),
status="linked",
access_expires_at=datetime.now(timezone.utc)
+ timedelta(seconds=int(token["expires_in"])),
)
)
await DB_SESSION_MNG.execute_lambda_write(Account.DBType(), save)
async def lock_user(s, user_id, provider):
await s.execute(
text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"),
{"key": f"social:{user_id}:{provider}"},
)
async def account(s, user_id, provider):
return (
await s.execute(
select(Account).where(
Account.user_id == user_id,
Account.provider == provider,
Account.deleted == False,
Account.status.in_(["linked", "needs_reauth"]),
)
)
).scalar_one_or_none() # noqa: E712
async def get_usable_token(s, row, client):
if row.status != "linked":
raise SocialError("ACCOUNT_NEEDS_REAUTH", reauth=True)
token = decrypt(row.access_token)
if row.access_expires_at and row.access_expires_at > datetime.now(
timezone.utc
) + timedelta(minutes=5):
return token
result = await adapter(row.provider).refresh(
decrypt(row.refresh_token) if row.refresh_token else token, client=client
)
# 회전 토큰 저장은 한 UPDATE. 호출측은 이 트랜잭션을 커밋한 뒤에만 게시를 시작한다.
await s.execute(
update(Account)
.where(Account.account_id == row.account_id)
.values(
access_token=encrypt(result["access_token"]),
refresh_token=encrypt(result["refresh_token"])
if result.get("refresh_token")
else None,
access_expires_at=datetime.now(timezone.utc)
+ timedelta(seconds=int(result["expires_in"])),
scopes=result["scope"].split(),
updated_at=datetime.now(timezone.utc),
last_error=None,
)
)
return result["access_token"]
async def disconnect(user_id, provider):
async def run(s):
await lock_user(s, user_id, provider)
await s.execute(
update(Account)
.where(
Account.user_id == user_id,
Account.provider == provider,
Account.deleted == False,
)
.values(
status="revoked",
access_token=None,
refresh_token=None, # noqa: E712
updated_at=datetime.now(timezone.utc),
)
)
await DB_SESSION_MNG.execute_lambda_write(Account.DBType(), run)