카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 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>
5.5 KiB
사장님 에이전트 — 1단계 · 카카오톡 채널 신원 연결
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지. 에이전트는 카카오톡 안에 있지 않다. 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도 붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
이 문서는 1단계(신원 연결) 만 다룬다. 도구 레지스트리·런타임은 아직 없다.
왜 신원 연결이 먼저인가
카카오 채널이 주는 발화자 식별자는 채널 단위 익명 키다. 우리 user_id 와 아무 관계가 없다.
이 레포의 모든 엔드포인트는 place_crud.get_place(s, owner_user_id, place_id) 로
"없는 것과 남의 것을 똑같이 PLACE_NOT_FOUND 로 답하는" 관례를 지킨다. 채널에서 온 발화에는
그 owner_user_id 를 줄 근거가 없다 — 연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.
절차 — 사장님은 두 번 누른다
/sites내 사이트 화면의카카오톡으로 관리 · 채널 연결카드 → [카카오톡 연결]- 화면에 뜬 6자리 코드를 카카오톡 채널에 보낸다
★ 연결 버튼을 사업장 화면에 두지 않는다. 연결은 user 단위인데 버튼이 사업장 안에 있으면
사장님은 업장마다 연결해야 하는 줄 안다(SocialConnectionCard 가 같은 이유로 거기 있다).
★ KAKAO_CHANNEL_PUBLIC_ID 가 비면 카드는 그리되 버튼이 죽는다. 어디에 코드를 칠지
말해 줄 수 없는데 코드만 발급하면 사장님에게는 고장난 화면이다. 숨기지는 않는다 — 숨기면
기능이 없는 것처럼 보인다(2026-09-14 Threads 카드에서 실제로 겪었다).
표 — owner_kakao_links (마이그레이션 0021)
user_id · channel_user_key · code_sha · code_expires_at · code_attempts · status · linked_at · last_seen_at
| 인덱스 | 무엇을 막나 |
|---|---|
uq_kakao_link_user (PENDING·LINKED) |
한 사장님에 활성 연결 하나. 다시 눌러도 행이 늘지 않고 코드만 바뀐다 |
uq_kakao_link_channel_key (LINKED) |
★ 한 카카오 계정은 한 사장님에만. 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다 |
uq_kakao_link_code (PENDING) |
코드 한 행 지목 |
코드는 평문으로 저장하지 않는다(code_sha). 사장님이 손으로 치는 짧은 값이라, 평문이면
DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다. 그래서 화면에 한 번 뜨고 다시 볼 수 없다 —
카드는 항상 [코드 다시 받기] 를 함께 둔다.
코드 글자에서 0·O·1·I·L 을 뺐다. 잘못 읽어 실패하면 원인이 화면에 안 보이고
"연결이 안 된다" 로만 보인다.
일회성은 값이 아니라 CAS 가 보장한다
UPDATE owner_kakao_links
SET status='LINKED', channel_user_key=:key, linked_at=now(), code_sha=NULL
WHERE code_sha=:sha AND deleted=false AND status='PENDING'
AND code_expires_at > now() AND code_attempts < :max
RETURNING user_id;
조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다).
실패는 전부 같은 에러다(KAKAO_LINK_CODE_INVALID). "없는 코드"·"만료"·"시도 초과" 를
구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.
★ 소비 엔드포인트는 아직 없다
코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다. 검증 없는 공개 소비 경로를 먼저 만들면 누구나 코드를 대입해 남의 계정에 자기 카톡을 붙인다 — 이 표가 막으려던 바로 그 일이다.
지금 redeem() 은 서비스 함수로만 있고 라우터에 붙어 있지 않다.
API
| 메서드/경로 | 역할 |
|---|---|
GET /v1/agent/kakao/link |
연결 상태. ★ 코드 평문은 주지 않는다 |
POST /v1/agent/kakao/link/code |
일회용 코드 발급. 평문은 이 응답에서 한 번만 |
POST /v1/agent/kakao/link/disconnect |
해제. 행은 REVOKED 로 남긴다 |
셋 다 Cache-Control: no-store · Referrer-Policy: no-referrer · X-Robots-Tag: noindex 다.
설정
KAKAO_CHANNEL_PUBLIC_ID= # 비면 연결 기능이 꺼진다(카드는 보이고 버튼만 죽는다)
KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5
config/agent_config.py 는 social_config.py 와 일부러 갈랐다. SNS 게재는 되돌릴 수 없는
대외 발화이고, 에이전트는 사장님이 자기 사이트를 고치는 창구다. 한 파일에 섞이면
"이 값이 무엇을 여는가" 가 흐려진다.
다음 단계
| 내용 | 심사 | |
|---|---|---|
| 2 | 도구 레지스트리 + 런타임 + 빌더 화면 채팅창 | 없음 |
| 3 | 등급 순으로 도구 개방 — 읽기 → 되돌림 가능 → 반쯤 → 되돌림 불가 | 없음 |
| 4 | 카카오 채널 웹훅을 입구로 추가(서명 검증 + redeem 연결) |
채널 + 챗봇 |
★ 도구는 반드시 서비스 계층을 통과한다. crud 를 직접 부르면 업종 스키마 검증·출처 필수·
정정본 보호가 통째로 사라지고, 아무 증상 없이 사라진다.
collect_service.store_facts 가 크롤러에 걸어 둔 문과 같은 문이다.