o2o-site-AEO/solution/backend/services/social_service.py
김성경 df1556f2b1 [fix] solution/backend: 쓰레드 게시 잡이 job_type 번호가 어긋나 워커에 조용히 씹혔다
큐 삽입 쪽(social_crud.decide, social_service.create_draft/publish_reused_text)이
JobType enum(SOCIAL_DRAFT=9, SOCIAL_POST=10)을 안 쓰고 숫자를 하드코딩(8, 9)해서,
워커 디스패처(worker/handlers.py)가 그 숫자로 엉뚱한 핸들러를 불렀다 — "게시" 잡(9)은
run_draft로, "초안" 잡(8)은 run_rollback으로. 둘 다 대상 상태 조건이 안 맞아 에러 없이
{"skipped": true}로 끝나 DONE 처리됐다 — 승인해도 실제로는 한 번도 게시되지 않는데
로그만 보면 정상으로 보이는 조용한 실패였다. SOCIAL_POSTING_ENABLED가 계속 꺼져 있어
지금까지 드러나지 않았다(2026-09-14부터 있던 버그).

- 세 호출부를 JobType.SOCIAL_DRAFT.value/SOCIAL_POST.value로 교체
- 기존 테스트의 job_type 기대값(8→9, 9→10)도 실제 enum에 맞게 수정
- 신규: 디스패처 매핑 정적 대조 + 실제 큐 삽입값으로 하는 엔드투엔드 회귀 테스트
  (되돌려서 새 테스트가 실패하는 것까지 확인함)

전체 회귀 70 passed
2026-09-22 11:36:21 +09:00

629 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SNS 초안·승인·게시. 일반 발행과 분리해 사장님의 명시적인 요청만 처리한다."""
import hashlib
import json
from config import social_config as config
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID
import httpx
from fastapi import HTTPException
from sqlalchemy import select, update, text
from sqlalchemy.dialects.postgresql import insert
from common.database.model.models import (
place_social_posts as Post,
sites,
places,
place_facts,
owner_social_accounts as Account,
)
from common.enums import SiteStatus, ErrorType, JobType, PUBLISHABLE_FACT_STATUSES, SocialProvider
from crud.place_crud import PlaceCRUD
from crud import social_crud as db
from services import site_payload, social_account_service as accounts
from services.external import gemini_text
from services.external.social import (
SocialError,
SocialOutcomeUnknown,
adapter,
weighted_length,
)
def sha(token):
return hashlib.sha256(token.encode()).hexdigest()
def public_post(row):
return {
"account_bound": bool(row.account_id),
**{
key: (str(value) if isinstance(value, UUID) else value)
for key in (
"post_id",
"provider",
"body",
"link_url",
"status",
"approval_expires_at",
"approval_channel",
"permalink",
"posted_at",
"last_error",
)
if (value := getattr(row, key)) is not None
},
}
async def owned_place(s, user_id, place_id):
err, place = await PlaceCRUD().get_place(s, UUID(str(user_id)), UUID(str(place_id)))
if err != ErrorType.SUCCESS or not place:
raise HTTPException(404, "PLACE_NOT_FOUND")
return place
async def target(s, user_id, place_id):
place = await owned_place(s, user_id, place_id)
site = (
await s.execute(
select(sites)
.where(sites.place_id == place.place_id, sites.deleted == False)
.with_for_update()
)
).scalar_one_or_none() # noqa: E712
if (
not site
or site.status != SiteStatus.PUBLISHED.value
or not site.current_version_id
or not site.domain
):
raise HTTPException(409, "SOCIAL_PUBLISHED_FIXED_URL_REQUIRED")
return (
place,
site,
site_payload.publish_origin() + "/s/" + site_payload.publish_slug(place, site),
)
async def list_posts(user_id, place_id):
async def run(s):
await owned_place(s, user_id, place_id)
rows = (
(
await s.execute(
select(Post)
.where(
Post.place_id == place_id,
Post.user_id == user_id,
Post.deleted == False,
)
.order_by(Post.created_at.desc())
.limit(20)
)
)
.scalars()
.all()
) # noqa: E712
account = await accounts.account(s, user_id, 2)
return {
"posts": [public_post(r) for r in rows],
"connection_enabled": accounts.configured(),
"posting_enabled": posting_enabled(),
"account": (
{
"handle": account.handle,
"profile_url": account.profile_url,
"status": account.status,
}
if account
else None
),
}
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"
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)
rows = (
(
await s.execute(
select(place_facts).where(
place_facts.place_id == place.place_id,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(
[v.value for v in PUBLISHABLE_FACT_STATUSES]
),
(
place_facts.expires_at.is_(None)
| (place_facts.expires_at > datetime.now(timezone.utc))
),
)
)
)
.scalars()
.all()
)
facts = [
{
"key": r.key,
"value": r.value,
"fact_id": str(r.fact_id),
"unit": r.unit,
"source_url": r.source_url,
}
for r in rows
if r.value and r.value.strip() and r.key != "meta_description"
]
if not facts:
raise HTTPException(409, "NO_GROUNDED_FACTS")
row_id = (
await s.execute(
insert(Post)
.values(
place_id=place_id,
user_id=user_id,
site_version_id=site.current_version_id,
provider=provider,
link_url=url,
grounded_facts=facts,
)
.on_conflict_do_nothing(
index_elements=["place_id", "site_version_id"],
index_where=text("deleted=false"),
)
.returning(Post.post_id)
)
).scalar_one_or_none()
row = (
await s.execute(
select(Post).where(
Post.place_id == place_id,
Post.site_version_id == site.current_version_id,
Post.deleted == False,
)
)
).scalar_one() # noqa: E712
# 생성 실패는 같은 원고 행을 재시도한다. 이미 쓴 글은 유료 재생성하지 않는다.
if not row_id and row.status == "FAILED" and not row.body:
row.status, row.last_error = "DRAFTING", None
row.updated_at = datetime.now(timezone.utc)
await s.flush()
row_id = row.post_id
if row_id:
await db.enqueue(s, row.post_id, JobType.SOCIAL_DRAFT.value)
return public_post(row)
return await db.transaction(run)
async def run_draft(job):
post_id = UUID(job["payload"]["post_id"])
async def load(s):
row = await s.get(Post, post_id)
if not row or row.deleted or row.status != "DRAFTING":
return None
place, _, url = await target(s, row.user_id, row.place_id)
if row.link_url != url:
raise SocialError("PUBLISH_URL_CHANGED")
return place.name, row.provider, row.link_url, row.grounded_facts
try:
data = await db.transaction(load)
if not data:
return {"skipped": True}
name, provider, url, facts = data
body = await gemini_text.generate_social_post(
name,
[
gemini_text.FactInput(
key=f["key"], label=f["key"], value=f["value"], unit=f.get("unit")
)
for f in facts
],
url,
provider,
)
async def save(s):
await s.execute(
update(Post)
.where(Post.post_id == post_id, Post.status == "DRAFTING")
.values(
body=body,
status="DRAFT",
last_error=None,
updated_at=datetime.now(timezone.utc),
)
)
await db.transaction(save)
return {"post_id": str(post_id)}
except Exception:
await set_failure(post_id, "DRAFT_FAILED", expected="DRAFTING")
raise SocialError("DRAFT_FAILED") from None
async def set_failure(post_id, code, *, expected="POSTING", status="FAILED"):
async def run(s):
await s.execute(
update(Post)
.where(Post.post_id == post_id, Post.status == expected)
.values(
status=status, last_error=code, updated_at=datetime.now(timezone.utc)
)
)
await db.transaction(run)
async def request_approval(user_id, post_id):
token = secrets.token_urlsafe(32)
async def run(s):
initial = await s.get(Post, post_id)
if not initial or initial.deleted or initial.user_id != user_id:
raise HTTPException(404, "PLACE_NOT_FOUND")
_, _, url = await target(s, user_id, initial.place_id)
# 생성/승인/게시 모두 site → post 순서로 잠근다. 반대면 동시 재요청이 교착된다.
row = (
await s.execute(
select(Post)
.where(
Post.post_id == post_id,
Post.user_id == user_id,
Post.deleted == False,
)
.with_for_update()
.execution_options(populate_existing=True)
)
).scalar_one() # noqa: E712
if row.link_url != url:
raise HTTPException(409, "PUBLISH_URL_CHANGED")
expired = row.approval_expires_at and row.approval_expires_at <= datetime.now(
timezone.utc
)
resendable = row.status == "PENDING_APPROVAL" and (expired or row.last_error)
reconnectable = row.status == "APPROVED" and row.account_id is None
if (
row.status not in ("DRAFT", "EXPIRED", "DECLINED", "FAILED")
and not resendable
and not reconnectable
) or not row.body:
return {"post": public_post(row), "already_processed": True}
account = (
await accounts.account(s, user_id, row.provider)
if posting_enabled()
else None
)
if posting_enabled() and (not account or account.status != "linked"):
raise HTTPException(409, "ACCOUNT_CONNECTION_REQUIRED")
row.status = "PENDING_APPROVAL"
row.account_id = account.account_id if account else None
row.approval_token_sha = sha(token)
row.approval_channel = "screen"
row.approval_sent_at = None
row.approval_expires_at = datetime.now(timezone.utc) + timedelta(
hours=max(1, min(168, int(config.get("SOCIAL_APPROVAL_HOURS", "24"))))
)
row.updated_at = datetime.now(timezone.utc)
row.last_error = None
await s.flush()
return {
"post": public_post(row),
"approval_path": f"/approve/{post_id}?t={token}",
}
result = await db.transaction(run)
if result.get("approval_path"):
from services.notify_service import request_approval as notify
await notify(post_id, user_id, token, result["approval_path"])
return result
async def approval(post_id, token, *, approve=None):
async def run(s):
row = (
await s.execute(
select(Post).where(
Post.post_id == post_id,
Post.deleted == False,
Post.approval_token_sha == sha(token),
)
)
).scalar_one_or_none() # noqa: E712
if not row:
raise HTTPException(404, "APPROVAL_NOT_FOUND")
await owned_place(s, row.user_id, row.place_id)
account = await s.get(Account, row.account_id) if row.account_id else None
if approve is None:
result = public_post(row)
result["account_handle"] = account.handle if account else None
return result
if approve and row.status == "PENDING_APPROVAL":
_, _, url = await target(s, row.user_id, row.place_id)
if row.link_url != url or (
row.account_id
and (not account or account.deleted or account.status != "linked")
):
raise HTTPException(409, "APPROVAL_TARGET_CHANGED")
applied = await db.decide(s, post_id, sha(token), approve, "link")
return {
"applied": applied,
"message": "승인했습니다"
if applied and approve
else "게재하지 않습니다"
if applied
else "이미 처리됐거나 만료된 요청입니다",
}
return await db.transaction(run)
async def owner_decision(user_id, post_id, approve):
# 화면은 비밀 링크를 저장하지 않아도 승인할 수 있다. 신원 범위만 다르고 CAS는 같다.
async def load(s):
row = await s.get(Post, post_id)
if not row or row.deleted or row.user_id != user_id:
raise HTTPException(404, "PLACE_NOT_FOUND")
await owned_place(s, user_id, row.place_id)
if approve:
_, _, url = await target(s, user_id, row.place_id)
account = await s.get(Account, row.account_id) if row.account_id else None
if url != row.link_url or (
row.account_id
and (not account or account.deleted or account.status != "linked")
):
raise HTTPException(409, "APPROVAL_TARGET_CHANGED")
return {
"applied": await db.decide(
s, post_id, row.approval_token_sha, approve, "builder"
)
}
return await db.transaction(load)
async def run_post(job):
post_id = UUID(job["payload"]["post_id"])
if not posting_enabled():
await set_failure(post_id, "SOCIAL_POSTING_DISABLED", expected="APPROVED")
raise SocialError("SOCIAL_POSTING_DISABLED")
async def claim(s):
return (
await s.execute(
update(Post)
.where(
Post.post_id == post_id,
Post.deleted == False, # noqa: E712
Post.status == "APPROVED",
)
.values(status="POSTING", updated_at=datetime.now(timezone.utc))
.returning(Post.user_id, Post.provider, Post.account_id)
)
).first()
claimed = await db.transaction(claim)
if not claimed:
return {"skipped": True}
user_id, provider, account_id = claimed
async with httpx.AsyncClient(timeout=20) as client:
try:
async def refresh(s):
await accounts.lock_user(s, user_id, provider)
row = await s.get(Account, account_id)
if (
not row
or row.deleted
or row.user_id != user_id
or row.status != "linked"
):
raise SocialError("ACCOUNT_CONNECTION_REQUIRED")
return await accounts.get_usable_token(s, row, client)
try:
token = await db.transaction(refresh)
except Exception:
async def invalidate(s):
await s.execute(
update(Account)
.where(
Account.account_id == account_id, Account.status == "linked"
)
.values(
status="needs_reauth", last_error="TOKEN_REFRESH_FAILED"
)
)
await db.transaction(invalidate)
raise SocialError("ACCOUNT_NEEDS_REAUTH", reauth=True) from None
async def publish(s):
await accounts.lock_user(s, user_id, provider)
initial = await s.get(Post, post_id)
_, _, url = await target(s, user_id, initial.place_id)
row = (
await s.execute(
select(Post)
.where(Post.post_id == post_id)
.with_for_update()
.execution_options(populate_existing=True)
)
).scalar_one()
if row.status != "POSTING":
return {"skipped": True}
account = await s.get(Account, account_id)
if not account or account.status != "linked" or account.deleted:
raise SocialError("ACCOUNT_CONNECTION_REQUIRED")
# 마지막까지 사이트 잠금을 유지한다: 게시 도중 사이트를 내리는 경합을 직렬화한다.
if url != row.link_url:
raise SocialError("PUBLISH_URL_CHANGED")
if (
weighted_length(row.body, provider)
> adapter(provider).weighted_limit()
):
raise SocialError("TEXT_TOO_LONG")
identity = await adapter(provider).me(token, client=client)
if str(identity["id"]) != account.provider_user_id:
raise SocialError("ACCOUNT_IDENTITY_CHANGED", reauth=True)
result = await adapter(provider).publish(row.body, token, client=client)
row.status, row.provider_post_id, row.permalink = (
"POSTED",
result["id"],
result["permalink"],
)
row.posted_at = row.updated_at = datetime.now(timezone.utc)
row.last_error = None
await s.execute(
update(places)
.where(places.place_id == row.place_id)
.values(content_updated_at=row.posted_at)
)
# 재빌드는 사이트당 큐 키를 쓰되 여기서 외부 게시를 다시 호출하지 않는다.
await s.execute(
text("""INSERT INTO jobs(job_type,payload,dedupe_key) VALUES
(4,CAST(:payload AS jsonb),:key) ON CONFLICT (dedupe_key)
WHERE status IN (1,2) AND dedupe_key IS NOT NULL DO NOTHING"""),
{
"payload": json.dumps(
{
"place_id": str(row.place_id),
"owner_user_id": str(user_id),
"publish": True,
}
),
"key": f"social-build:{post_id}",
},
)
await s.execute(text("SELECT pg_notify('web4ai_job', '')"))
return {"post_id": str(post_id), "permalink": result["permalink"]}
# API 성공 뒤 DB 저장 실패도 결과 불명이다: FAILED로 떨어뜨려 재게시시키지 않는다.
return await db.transaction(publish)
except SocialOutcomeUnknown:
await set_failure(post_id, "POST_RESULT_UNKNOWN", status="UNKNOWN")
raise SocialOutcomeUnknown("POST_RESULT_UNKNOWN") from None
except (SocialError, HTTPException) as ex:
if getattr(ex, "reauth", False):
async def reauth(s):
await s.execute(
update(Account)
.where(
Account.account_id == account_id, Account.status == "linked"
)
.values(
status="needs_reauth", last_error="ACCOUNT_NEEDS_REAUTH"
)
)
await db.transaction(reauth)
await set_failure(
post_id,
str(ex)
if isinstance(ex, SocialError)
else "PUBLISH_TARGET_UNAVAILABLE",
)
raise SocialError("POST_FAILED") from None
except Exception:
await set_failure(post_id, "POST_RESULT_UNKNOWN", status="UNKNOWN")
raise SocialOutcomeUnknown("POST_RESULT_UNKNOWN") from None
async def publish_reused_text(user_id, place_id, body: str):
"""미니블로그 승인 문구를 그대로 쓰레드에 낸다 — 승인 자체가 발화 동의라 별도 승인을
또 묻지 않는다(2026-09-21, DECISIONS 7-1-2 개정: 미니블로그 문구를 그대로 재사용하는
경우에 한정한 예외). 연동 안 돼 있거나 조건 미달이면 조용히 None을 돌려준다 — 호출부가
실패로 취급하지 않는다."""
if not posting_enabled():
return None
async def run(s):
account = await accounts.account(s, user_id, SocialProvider.THREADS.value)
if not account or account.status != "linked":
return None
_place, site, url = await target(s, user_id, place_id)
full_text = f"{body}\n\n{url}"
row_id = (
await s.execute(
insert(Post)
.values(
place_id=place_id,
user_id=user_id,
site_version_id=site.current_version_id,
provider=SocialProvider.THREADS.value,
account_id=account.account_id,
link_url=url,
body=full_text,
status="APPROVED",
decided_at=datetime.now(timezone.utc),
decided_via="mini_blog",
)
.on_conflict_do_nothing(
index_elements=["place_id", "site_version_id"],
index_where=text("deleted=false"),
)
.returning(Post.post_id)
)
).scalar_one_or_none()
if row_id:
await db.enqueue(s, row_id, JobType.SOCIAL_POST.value)
return row_id
try:
return await db.transaction(run)
except HTTPException:
return None