카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 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>
52 lines
2.2 KiB
Python
52 lines
2.2 KiB
Python
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다.
|
|
|
|
★ 소비(redeem) 엔드포인트는 여기 없다. 코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은
|
|
자체 서명 검증을 갖춘 뒤에야 열 수 있다. 검증 없는 공개 소비 경로를 먼저 만들면
|
|
누구나 코드를 대입해 남의 계정에 자기 카톡을 붙일 수 있다 — 이 표가 막으려던 바로 그 일이다.
|
|
"""
|
|
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
|
|
from common.models.gmodel import UserInfo
|
|
from router.v1.validator.dependencies import IsValidAccessToken
|
|
from services import kakao_link_service as service
|
|
from services.kakao_link_service import KakaoLinkError
|
|
|
|
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
|
|
|
|
|
|
def private_response(response: Response):
|
|
"""코드가 오가는 응답이다 — 캐시·리퍼러·색인을 모두 막는다(social 라우터와 같은 규약)."""
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.headers["Referrer-Policy"] = "no-referrer"
|
|
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
|
|
|
|
|
@router.get("/link")
|
|
async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
"""연결 상태. 사업장을 고르지 않아도 답할 수 있어야 하는 값이다 — 계정은 사람에 붙는다."""
|
|
private_response(response)
|
|
return await service.state(UUID(user.user_id))
|
|
|
|
|
|
@router.post("/link/code")
|
|
async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
"""일회용 코드를 낸다. ★ 평문 코드는 이 응답에서 한 번만 나가고 DB 에는 sha256 만 남는다."""
|
|
private_response(response)
|
|
try:
|
|
return await service.issue_code(UUID(user.user_id))
|
|
except KakaoLinkError as ex:
|
|
raise HTTPException(409, str(ex)) from ex
|
|
|
|
|
|
@router.post("/link/disconnect")
|
|
async def disconnect(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
private_response(response)
|
|
try:
|
|
await service.disconnect(UUID(user.user_id))
|
|
except KakaoLinkError as ex:
|
|
raise HTTPException(409, str(ex)) from ex
|
|
return {"disconnected": True}
|