카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 user_id 와 관계가 없다. 다른 엔드포인트는 전부 place_crud.get_place(s, owner_user_id, place_id) 로 소유자 범위를 지키는데 채널 발화에는 그 owner_user_id 를 줄 근거가 없다 — 매핑이 없으면 채널 진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다. - postgres-init: owner_kakao_links(0021 + init.sql). 부분 유니크 셋 중 uq_kakao_link_channel_key(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다 - services/kakao_link_service: 일회성은 코드 값이 아니라 WHERE status='PENDING' CAS 한 문장이 보장한다. 실패는 전부 같은 에러 — 없는 코드·만료·시도초과를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다 - 코드는 sha256 만 저장. 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧 연결 권한이다. 글자에서 0·O·1·I·L 제외 — 잘못 읽으면 원인이 화면에 안 보인다 - router/v1/agent/kakao: 셋 다 no-store·no-referrer·noindex. ★ 소비(redeem) 엔드포인트는 일부러 없다 — 웹훅 서명 검증 전에 공개 소비 경로를 열면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 - config/agent_config: social_config 와 일부러 가름. SNS 게재는 되돌릴 수 없는 대외 발화, 에이전트는 자기 사이트를 고치는 창구 — 승인 강도가 다르다 - frontend/features/agent: /sites 의 Threads 카드 옆. 연결은 사람 단위라 같은 자리다 - docs/AGENT.md 신설, CLAUDE.md 색인·함정, DEVLOG test_kakao_link.py 15 passed. 전체 780 passed / 50 failed — 그 50건은 HEAD 에서도 동일(워크트리 대조), 기존 이슈로 이번 변경과 무관. npm run lint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
8.1 KiB
Python
202 lines
8.1 KiB
Python
"""카카오톡 채널 발화자를 우리 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)
|