"""카카오톡 채널 발화자를 우리 user_id 에 묶는다 — 에이전트의 모든 도구가 이 매핑 위에 선다. ★ 이 파일이 없으면 채널 진입점만 소유자 범위 밖에 놓인다. 다른 엔드포인트는 전부 place_crud.get_place(s, owner_user_id, place_id) 로 "없는 것과 남의 것을 똑같이 PLACE_NOT_FOUND 로" 답하는데, 채널에서 온 발화에는 그 owner_user_id 를 줄 근거가 없다 — 카카오가 주는 것은 **채널 단위 익명 키**뿐이다. ★ 일회성은 코드 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다. 조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다). """ 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 # 사장님이 카톡 대화창에 손으로 친다. 혼동하는 글자(0·O·1·I·L)는 뺀다 — # 잘못 읽어 실패하면 원인이 화면에 안 보이고 "연결이 안 된다" 로만 보인다. _CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" _CODE_LENGTH = 6 class KakaoLinkError(RuntimeError): """도메인 예외. 코드 문자열만 담고 HTTP 변환은 라우터가 한다(social 과 같은 규약).""" 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 와 같은 방식). 행 잠금이 아니라 advisory 인 이유: PENDING 행이 아직 없을 수도 있어서, 잠글 행 자체가 없는 순간이 존재한다.""" 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: """일회용 코드를 낸다. 이미 PENDING 이면 **같은 행의 코드만 교체**한다. ★ 행을 새로 만들지 않는 이유는 uq_kakao_link_user 때문만이 아니다 — 사장님이 버튼을 두 번 눌렀을 때 옛 코드가 살아 있으면, 둘 중 어느 것이 먹을지 화면이 말해 줄 수 없다.""" 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 를 돌려준다. 실패는 전부 같은 에러다. ★ "없는 코드" 와 "남의 코드" 와 "만료" 를 구분해 답하지 않는다 — 구분해 주면 짧은 코드의 유효성을 외부에서 탐색할 수 있다. ★ 아직 공개 엔드포인트가 아니다. 채널 웹훅(4단계)이 이 함수를 부르고, 그 웹훅은 자체 서명 검증을 따로 갖춰야 한다.""" 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. 매핑이 없으면 None 이고, 호출측은 거기서 멈춰야 한다. ★ None 을 "아무 사장님" 으로 흘려보내면 이 기능 전체가 무의미해진다.""" 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 disconnect(user_id: UUID) -> None: """연결을 끊는다. 행은 REVOKED 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다. ★ channel_user_key 도 남긴다. 부분 유니크가 status='LINKED' 조건이라 재연결을 막지 않는다.""" 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)