충돌은 이 브랜치의 주석 정리 커밋(11d30bb)과 main 의 기능 변경이 겹친 백엔드 9개 파일 —
main 쪽을 그대로 썼다(기능이 main 에만 있다). AGENTS.md 는 양쪽 링크를 모두 두고,
DEVLOG 는 요약본 위에 main 의 09-28 에이전트 항목 셋을 붙였다.
site eslint 통과 · vitest 123 passed · frontend tsc·eslint 통과 · 백엔드 py_compile 통과
(백엔드 pytest 는 이 기계에 테스트 DB 계정이 없어 실행하지 못함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
6.7 KiB
Python
199 lines
6.7 KiB
Python
"""카카오톡 채널 발화자를 우리 user_id 에 묶는다 — 에이전트의 모든 도구가 이 매핑 위에 선다."""
|
|
|
|
import hashlib
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import select, text, update
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import owner_kakao_links as Link
|
|
from common.enums import KakaoLinkStatus
|
|
from config import agent_config as config
|
|
|
|
# 사장님이 카톡 대화창에 손으로 친다.
|
|
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
_CODE_LENGTH = 6
|
|
|
|
|
|
class KakaoLinkError(RuntimeError):
|
|
"""도메인 예외."""
|
|
|
|
def __init__(self, code="KAKAO_LINK_FAILED"):
|
|
super().__init__(code)
|
|
|
|
|
|
def enabled() -> bool:
|
|
return config.kakao_link_enabled()
|
|
|
|
|
|
def _now():
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _sha(code: str) -> str:
|
|
return hashlib.sha256(code.strip().upper().encode()).hexdigest()
|
|
|
|
|
|
def _new_code() -> str:
|
|
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
|
|
|
|
|
|
async def _lock_user(s, user_id):
|
|
"""연결·재발급·해제가 같은 잠금을 공유한다(social_account_service.lock_user 와 같은 방식)."""
|
|
await s.execute(
|
|
text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"),
|
|
{"key": f"kakao_link:{user_id}"},
|
|
)
|
|
|
|
|
|
async def _active(s, user_id):
|
|
return (
|
|
await s.execute(
|
|
select(Link).where(
|
|
Link.user_id == user_id,
|
|
Link.deleted.is_(False),
|
|
Link.status.in_([KakaoLinkStatus.PENDING.value, KakaoLinkStatus.LINKED.value]),
|
|
)
|
|
)
|
|
).scalars().first()
|
|
|
|
|
|
async def state(user_id: UUID) -> dict:
|
|
"""빌더 카드가 읽는 값."""
|
|
|
|
async def run(s):
|
|
row = await _active(s, user_id)
|
|
return {
|
|
"connection_enabled": enabled(),
|
|
"channel_url": config.channel_url(),
|
|
"status": row.status if row else None,
|
|
"linked_at": row.linked_at.isoformat() if row and row.linked_at else None,
|
|
"code_expires_at": (
|
|
row.code_expires_at.isoformat()
|
|
if row and row.status == KakaoLinkStatus.PENDING.value and row.code_expires_at
|
|
else None
|
|
),
|
|
}
|
|
|
|
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
|
|
|
|
|
async def issue_code(user_id: UUID) -> dict:
|
|
"""일회용 코드를 낸다."""
|
|
if not enabled():
|
|
raise KakaoLinkError("KAKAO_LINK_DISABLED")
|
|
|
|
code = _new_code()
|
|
expires = _now() + timedelta(minutes=int(config.get("KAKAO_LINK_CODE_TTL_MIN", 10)))
|
|
|
|
async def run(s):
|
|
await _lock_user(s, user_id)
|
|
row = await _active(s, user_id)
|
|
if row is not None and row.status == KakaoLinkStatus.LINKED.value:
|
|
raise KakaoLinkError("KAKAO_LINK_ALREADY")
|
|
if row is None:
|
|
row = Link(user_id=user_id, status=KakaoLinkStatus.PENDING.value)
|
|
s.add(row)
|
|
row.code_sha = _sha(code)
|
|
row.code_expires_at = expires
|
|
row.code_attempts = 0
|
|
return {"code": code, "expires_at": expires.isoformat(), "channel_url": config.channel_url()}
|
|
|
|
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
|
|
|
|
|
async def redeem(code: str, channel_user_key: str) -> UUID:
|
|
"""채널에서 들어온 코드를 소비하고 user_id 를 돌려준다."""
|
|
sha = _sha(code)
|
|
max_attempts = int(config.get("KAKAO_LINK_MAX_ATTEMPTS", 5))
|
|
|
|
async def run(s):
|
|
# 한 문장 CAS.
|
|
row = (
|
|
await s.execute(
|
|
text("""UPDATE owner_kakao_links
|
|
SET status='LINKED', channel_user_key=:key, linked_at=now(),
|
|
last_seen_at=now(), code_sha=NULL, code_expires_at=NULL, updated_at=now()
|
|
WHERE code_sha=:sha AND deleted=false AND status='PENDING'
|
|
AND code_expires_at > now() AND code_attempts < :max
|
|
RETURNING user_id"""),
|
|
{"sha": sha, "key": channel_user_key, "max": max_attempts},
|
|
)
|
|
).first()
|
|
if row is None:
|
|
# 맞는 코드가 없으면 셀 행도 없다.
|
|
await s.execute(
|
|
text("""UPDATE owner_kakao_links SET code_attempts = code_attempts + 1, updated_at=now()
|
|
WHERE code_sha=:sha AND deleted=false AND status='PENDING'"""),
|
|
{"sha": sha},
|
|
)
|
|
raise KakaoLinkError("KAKAO_LINK_CODE_INVALID")
|
|
return row.user_id
|
|
|
|
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
|
|
|
|
|
async def resolve(channel_user_key: str) -> UUID | None:
|
|
"""채널 발화자 → user_id."""
|
|
|
|
async def run(s):
|
|
row = (
|
|
await s.execute(
|
|
select(Link).where(
|
|
Link.channel_user_key == channel_user_key,
|
|
Link.deleted.is_(False),
|
|
Link.status == KakaoLinkStatus.LINKED.value,
|
|
)
|
|
)
|
|
).scalars().first()
|
|
if row is None:
|
|
return None
|
|
row.last_seen_at = _now()
|
|
return row.user_id
|
|
|
|
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
|
|
|
|
|
async def linked_key(user_id) -> str | None:
|
|
"""사장님에게 연결된 카톡 발화자 키(botUserKey). 연결이 없거나 끊겼으면 None.
|
|
|
|
★ 승인 알림을 **먼저 보낼** 때 쓴다(Event API). resolve() 와 방향이 반대다 —
|
|
그건 들어온 발화자로 사장님을 찾는다. 여기서는 사장님으로 발화자를 찾는다."""
|
|
|
|
async def run(s):
|
|
row = (
|
|
await s.execute(
|
|
select(Link).where(
|
|
Link.user_id == user_id,
|
|
Link.deleted.is_(False),
|
|
Link.status == KakaoLinkStatus.LINKED.value,
|
|
Link.channel_user_key.is_not(None),
|
|
)
|
|
)
|
|
).scalars().first()
|
|
return row.channel_user_key if row is not None else None
|
|
|
|
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
|
|
|
|
|
async def disconnect(user_id: UUID) -> None:
|
|
"""연결을 끊는다."""
|
|
|
|
async def run(s):
|
|
await _lock_user(s, user_id)
|
|
result = await s.execute(
|
|
update(Link)
|
|
.where(
|
|
Link.user_id == user_id,
|
|
Link.deleted.is_(False),
|
|
Link.status.in_([KakaoLinkStatus.PENDING.value, KakaoLinkStatus.LINKED.value]),
|
|
)
|
|
.values(status=KakaoLinkStatus.REVOKED.value, code_sha=None, code_expires_at=None)
|
|
)
|
|
if result.rowcount == 0:
|
|
raise KakaoLinkError("KAKAO_LINK_NOT_FOUND")
|
|
|
|
await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|