[feat] solution/backend,frontend: 카카오톡 채널 신원 연결 — 에이전트 1단계

카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 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>
This commit is contained in:
hbyang 2026-09-21 16:01:58 +09:00
parent 94ae4a825a
commit 16b17bc91c
16 changed files with 944 additions and 0 deletions

View File

@ -78,6 +78,15 @@ ALIMTALK_PROFILE_ID=
ALIMTALK_SENDER=
ALIMTALK_TEMPLATE_CODE=
# ── 사장님 에이전트 · 카카오톡 채널 연결 ──────────────────────────────
# 사장님이 카톡으로 사이트를 고치려면, 채널 발화자(채널 단위 익명 키)를 우리 계정에
# 묶어야 한다. 빌더에서 코드를 받아 채널에 한 번 입력하는 절차다.
# ★ 이 값이 비면 연결 화면이 아예 안 뜬다 — 어디에 코드를 칠지 말해 줄 수 없는데
# 코드만 발급하면 사장님에게는 고장난 화면이다.
KAKAO_CHANNEL_PUBLIC_ID=
KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)

View File

@ -18,6 +18,7 @@
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
| **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) |
---
@ -161,6 +162,16 @@
- 토큰 갱신 저장 실패는 재연결. POSTING 중단·응답 유실은 UNKNOWN이며 자동 재게시 금지.
- 초기 SOCIAL_POSTING_ENABLED=0. [SOCIAL.md](docs/SOCIAL.md)의 실제 게시·해지 안내 페이지 전제를 확인한 뒤 연다.
## 에이전트에서 조용히 틀리는 것 (2026-09-21)
- **도구가 `crud` 를 직접 부르면 게이트가 통째로 뚫린다** — 업종 스키마 검증·출처 필수·정정본
보호가 사라지는데 **아무 증상이 없다**(값은 들어가고 빌드도 성공한다). 도구는 반드시
`services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다.
- **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다.
`owner_kakao_links` 매핑 없이 발화자를 믿으면 **채널 진입점만 소유자 범위 밖**에 놓인다.
- **코드 소비 경로를 웹훅 서명 검증보다 먼저 열지 않는다** — 누구나 6자리를 대입해 남의
계정에 자기 카톡을 붙일 수 있다. 지금 `redeem()` 이 라우터에 없는 이유다([AGENT.md](docs/AGENT.md)).
## 코드 규약
- **미결 사항은 코드로 풀지 않는다.** [DECISIONS.md](docs/DECISIONS.md) 1절이 보류한 것은

102
docs/AGENT.md Normal file
View File

@ -0,0 +1,102 @@
# 사장님 에이전트 — 1단계 · 카카오톡 채널 신원 연결
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지.
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
이 문서는 **1단계(신원 연결)** 만 다룬다. 도구 레지스트리·런타임은 아직 없다.
## 왜 신원 연결이 먼저인가
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**다. 우리 `user_id` 와 아무 관계가 없다.
이 레포의 모든 엔드포인트는 `place_crud.get_place(s, owner_user_id, place_id)` 로
"없는 것과 남의 것을 똑같이 `PLACE_NOT_FOUND` 로 답하는" 관례를 지킨다. 채널에서 온 발화에는
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
## 절차 — 사장님은 두 번 누른다
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
2. 화면에 뜬 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 가 보장한다
```sql
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` 가 크롤러에 걸어 둔 문과 같은 문이다.

View File

@ -1,5 +1,36 @@
# 개발 일지
## 2026-09-21 — 사장님 에이전트 1단계: 카카오톡 채널 신원 연결
**왜 이것부터인가**
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 `user_id` 와 관계가 없다.
다른 엔드포인트는 전부 `place_crud.get_place(s, owner_user_id, place_id)` 로 소유자 범위를
지키는데, 채널에서 온 발화에는 그 `owner_user_id` 를 줄 근거가 없다 — 매핑이 없으면
**채널 진입점만 소유자 범위 밖**에 놓이고 채널에 말을 건 아무나가 남의 가게를 고친다.
**한 일**
- `owner_kakao_links`(0021 + init.sql) — 부분 유니크 셋. 그중 `uq_kakao_link_channel_key`
(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다.
- `services/kakao_link_service.py` — 발급·소비·조회·해제. 일회성은 코드 값이 아니라
`WHERE status='PENDING'` CAS 한 문장이 보장한다. 실패는 전부 같은 에러(`KAKAO_LINK_CODE_INVALID`)다 —
"없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다.
- 코드는 sha256 만 저장한다. 사장님이 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧
연결 권한을 갖는다. 글자에서 `0·O·1·I·L` 을 뺐다 — 잘못 읽어 실패하면 원인이 화면에 안 보인다.
- `router/v1/agent/kakao.py` 셋(`link`·`link/code`·`link/disconnect`), 전부 `no-store`/`no-referrer`.
- 프론트 `features/agent/` — `/sites` 의 Threads 카드 옆에 나란히. 연결은 사람 단위라 같은 자리다.
- `config/agent_config.py` 를 `social_config.py` 와 **일부러 갈랐다** — SNS 게재는 되돌릴 수 없는
대외 발화, 에이전트는 자기 사이트를 고치는 창구. 승인 강도도 보관하는 것도 다르다.
**★ 일부러 안 만든 것 — 코드 소비 엔드포인트**
코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다.
검증 없는 공개 소비 경로를 먼저 만들면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 —
이 표가 막으려던 바로 그 일이다. `redeem()` 은 서비스 함수로만 두고 라우터에 붙이지 않았다.
**검증** — `test_kakao_link.py` 15 passed. 전체 백엔드 `780 passed / 50 failed`인데,
그 50건은 **같은 커밋 이전(HEAD)에서도 동일하게 50건**이다(워크트리로 대조 확인) —
`test_gemini*`·`test_site_theme`·`test_search_console_service` 등 기존 이슈이고 이번 변경과 무관하다.
`npm run lint`(frontend·admin·site) 통과.
## 2026-09-17 — 미니 블로그 — 지금 생성하기에 구간(시작~끝) 지정, 실배포 E2E 로 잡은 버그 1건
**한 일**

View File

@ -622,6 +622,40 @@ CREATE TABLE IF NOT EXISTS public.owner_social_accounts (
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_social_account ON public.owner_social_accounts(user_id, provider) WHERE deleted=false AND status IN ('linked','needs_reauth');
-- 카카오톡 채널 신원 연결 — 채널 발화자를 우리 user_id 에 묶는다(migrations/0021).
CREATE TABLE IF NOT EXISTS public.owner_kakao_links (
link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
-- 연결이 끝나야 채워진다. PENDING 행은 아직 누구의 카톡인지 모른다.
channel_user_key varchar(200),
code_sha varchar(64),
code_expires_at timestamptz,
-- 소진된 코드 시도 횟수. 짧은 코드라 무차별 대입을 이 값으로 끊는다.
code_attempts smallint NOT NULL DEFAULT 0,
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','LINKED','REVOKED')),
linked_at timestamptz,
last_seen_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted boolean NOT NULL DEFAULT false
);
-- 한 사장님은 활성 연결 하나. 다시 [연결하기] 를 눌러도 행이 늘지 않고 코드만 바뀐다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_user
ON public.owner_kakao_links(user_id)
WHERE deleted=false AND status IN ('PENDING','LINKED');
-- ★ 한 카카오 계정은 한 사장님에만 묶인다. 없으면 같은 카톡 계정이 여러 사장님에
-- 연결돼 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_channel_key
ON public.owner_kakao_links(channel_user_key)
WHERE deleted=false AND status='LINKED';
-- 코드 소비는 이 인덱스로 한 행을 집는다(일회성은 UPDATE ... WHERE status='PENDING' CAS 가 보장).
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_code
ON public.owner_kakao_links(code_sha)
WHERE deleted=false AND status='PENDING';
-- SNS: credentials and approval records never enter public payloads.
CREATE TABLE IF NOT EXISTS public.place_social_posts (
post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@ -0,0 +1,41 @@
-- 카카오톡 채널 신원 연결 — 채널 발화자를 우리 user_id 에 묶는다.
--
-- ★ 카카오 채널이 주는 발화자 식별자(channel_user_key)는 **채널 단위 익명 키**다.
-- 우리 user_id 와 아무 관계가 없다. 이 표가 없으면 채널 진입점만 소유자 범위
-- 밖에 놓여, 채널에 말을 건 아무나가 남의 가게를 고친다 — 다른 모든 엔드포인트가
-- place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
--
-- ★ 코드는 평문으로 두지 않는다(code_sha). 사장님이 카톡에 손으로 치는 값이라 짧고,
-- 짧은 값을 평문으로 들고 있으면 DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다.
CREATE TABLE IF NOT EXISTS public.owner_kakao_links (
link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
-- 연결이 끝나야 채워진다. PENDING 행은 아직 누구의 카톡인지 모른다.
channel_user_key varchar(200),
code_sha varchar(64),
code_expires_at timestamptz,
-- 소진된 코드 시도 횟수. 짧은 코드라 무차별 대입을 이 값으로 끊는다.
code_attempts smallint NOT NULL DEFAULT 0,
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','LINKED','REVOKED')),
linked_at timestamptz,
last_seen_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted boolean NOT NULL DEFAULT false
);
-- 한 사장님은 활성 연결 하나. 다시 [연결하기] 를 눌러도 행이 늘지 않고 코드만 바뀐다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_user
ON public.owner_kakao_links(user_id)
WHERE deleted=false AND status IN ('PENDING','LINKED');
-- ★ 한 카카오 계정은 한 사장님에만 묶인다. 없으면 같은 카톡 계정이 여러 사장님에
-- 연결돼 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_channel_key
ON public.owner_kakao_links(channel_user_key)
WHERE deleted=false AND status='LINKED';
-- 코드 소비는 이 인덱스로 한 행을 집는다(일회성은 UPDATE ... WHERE status='PENDING' CAS 가 보장).
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_code
ON public.owner_kakao_links(code_sha)
WHERE deleted=false AND status='PENDING';

View File

@ -699,6 +699,32 @@ class owner_social_accounts(MainTableMixin, MAIN_BASE):
__table_args__ = (Index("uq_social_account", "user_id", "provider", unique=True, postgresql_where=text("deleted=false AND status IN ('linked','needs_reauth')")),)
class owner_kakao_links(MainTableMixin, MAIN_BASE):
"""카카오톡 채널 발화자 ↔ 우리 user_id.
★ channel_user_key 는 **채널 단위 익명 키**라 우리 계정과 아무 관계가 없다. 이 표가
없으면 채널 진입점만 소유자 범위 밖에 놓인다 — 다른 엔드포인트가 전부
place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
★ 코드는 sha256 만 둔다. 사장님이 카톡에 손으로 치는 짧은 값이라, 평문으로 들고 있으면
DB 를 읽는 쪽이 곧 연결 권한을 갖는다."""
__tablename__ = "owner_kakao_links"
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False)
channel_user_key = Column(String(200), nullable=True)
code_sha = Column(String(64), nullable=True)
code_expires_at = Column(DateTime(timezone=True), nullable=True)
code_attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
linked_at = Column(DateTime(timezone=True), nullable=True)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("uq_kakao_link_user", "user_id", unique=True, postgresql_where=text("deleted=false AND status IN ('PENDING','LINKED')")),
Index("uq_kakao_link_channel_key", "channel_user_key", unique=True, postgresql_where=text("deleted=false AND status='LINKED'")),
Index("uq_kakao_link_code", "code_sha", unique=True, postgresql_where=text("deleted=false AND status='PENDING'")),
)
class place_social_posts(MainTableMixin, MAIN_BASE):
__tablename__ = "place_social_posts"
post_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)

View File

@ -113,6 +113,13 @@ class ErrorType(Enum):
JOB_ALREADY_QUEUED = auto() # 같은 dedupe_key 의 활성 잡이 이미 있다
JOB_NOT_DEAD = auto() # DEAD 가 아닌 잡을 재큐하려 함
# 카카오톡 채널 신원 연결 관련 에러
KAKAO_LINK_DISABLED = 2000 # KAKAO_CHANNEL_PUBLIC_ID 미설정 — 연결 화면 자체를 열지 않는다
KAKAO_LINK_ALREADY = auto() # 이미 연결된 사장님이 다시 코드를 받으려 함
KAKAO_LINK_CODE_INVALID = auto() # 코드가 없거나 만료 — ★ 없는 코드와 남의 코드를 구분해 답하지 않는다
KAKAO_LINK_NOT_FOUND = auto() # 해제할 연결이 없음
KAKAO_LINK_TAKEN = auto() # 그 카카오 계정이 이미 다른 사장님에 묶여 있다
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
@ -491,6 +498,17 @@ class SocialProvider(CodeEnum):
THREADS = 2
class KakaoLinkStatus(str, Enum):
"""owner_kakao_links.status.
★ 코드는 PENDING 행에만 산다. 연결이 끝나면 code_sha 를 비워 같은 코드가 두 번
먹지 않게 한다 — 일회성은 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다."""
PENDING = "PENDING" # 코드는 냈고 아직 카톡에서 입력되지 않았다
LINKED = "LINKED" # channel_user_key 가 붙었다
REVOKED = "REVOKED" # 사장님이 해제했다. 행은 남겨 이력을 잃지 않는다
class SocialPostStatus(str, Enum):
DRAFTING = "DRAFTING"
DRAFT = "DRAFT"

View File

@ -0,0 +1,38 @@
"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다).
★ SNS 게재(social_config)와 파일을 가른 이유는 도메인이 다르기 때문이다.
SNS 게재는 **되돌릴 수 없는** 대외 발화이고, 에이전트는 사장님이 자기 사이트를
고치는 창구다. 승인 강도도 보관하는 것도 다르다 — 설정이 한 파일에 섞이면
"이 값이 무엇을 여는가" 가 흐려진다.
"""
from pydantic_settings import BaseSettings
from config.config_models import _BASE
class AgentConfig(BaseSettings):
model_config = _BASE
# 카카오톡 채널 공개 ID(`_xaBcD` 형태). 사장님이 채널을 찾아 코드를 입력해야 하므로
# ★ 이 값이 없으면 연결 화면 자체를 열지 않는다 — 어디에 코드를 칠지 말해 줄 수
# 없는데 코드만 발급하면, 사장님에게는 고장난 화면이다(Threads 카드와 같은 규칙).
KAKAO_CHANNEL_PUBLIC_ID: str = ""
# 코드 수명. 사장님이 화면을 보고 카톡을 열어 치는 동작이라 짧아도 된다.
KAKAO_LINK_CODE_TTL_MIN: int = 10
# 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다. 시도 수로 끊는다.
KAKAO_LINK_MAX_ATTEMPTS: int = 5
def get(name, default=""):
return getattr(AgentConfig(), name, default) or default
def kakao_link_enabled() -> bool:
return bool(get("KAKAO_CHANNEL_PUBLIC_ID"))
def channel_url() -> str:
"""사장님이 눌러서 채널로 가는 주소. 공개 ID 가 없으면 빈 문자열이다."""
public_id = get("KAKAO_CHANNEL_PUBLIC_ID")
return f"http://pf.kakao.com/{public_id}" if public_id else ""

View File

@ -27,6 +27,7 @@ import router.v1.site.review
import router.v1.local.local
import router.v1.social.social
import router.v1.social.oauth
import router.v1.agent.kakao
API_SERVER_START_TIME = GTime.UTCStr()
@ -138,3 +139,4 @@ app.include_router(router.v1.local.local.weather_router)
app.include_router(router.v1.social.social.router)
app.include_router(router.v1.social.oauth.router)
app.include_router(router.v1.agent.kakao.router)

View File

@ -0,0 +1,51 @@
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다.
★ 소비(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}

View File

@ -0,0 +1,201 @@
"""카카오톡 채널 발화자를 우리 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)

View File

@ -0,0 +1,185 @@
"""카카오톡 채널 신원 연결.
여기서 지키는 것은 하나다 — **연결되지 않은 발화자는 어떤 사장님도 되지 못한다.**
나머지 검사(코드 일회성·만료·시도 제한·재발급)는 전부 그 한 줄을 지탱한다.
"""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import text
from services import kakao_link_service as service
from services.kakao_link_service import KakaoLinkError
@pytest.fixture(autouse=True)
def channel(monkeypatch):
"""KAKAO_CHANNEL_PUBLIC_ID 가 있어야 기능이 열린다. 없는 경우는 따로 검사한다."""
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh")
monkeypatch.setenv("KAKAO_LINK_CODE_TTL_MIN", "10")
monkeypatch.setenv("KAKAO_LINK_MAX_ATTEMPTS", "3")
async def test_채널_설정이_없으면_기능_자체가_꺼진다(db_engine, monkeypatch):
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "")
assert service.enabled() is False
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_DISABLED"):
await service.issue_code(uuid.uuid4())
# 화면은 자리를 그리되 버튼을 죽인다 — 상태 조회 자체는 살아 있어야 한다.
assert (await service.state(uuid.uuid4()))["connection_enabled"] is False
async def test_코드는_한_번만_먹는다(db_engine):
user_id, key = uuid.uuid4(), "kakao-key-1"
code = (await service.issue_code(user_id))["code"]
assert await service.redeem(code, key) == user_id
# ★ 두 번째는 실패해야 한다. 같은 코드로 다른 카톡 계정이 붙으면 연결의 의미가 없다.
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(code, "kakao-key-2")
async def test_연결된_발화자만_사장님이_된다(db_engine):
user_id, key = uuid.uuid4(), "kakao-key-3"
# ★ 이게 이 기능의 전부다 — 연결 전에는 어떤 값도 돌려주지 않는다.
assert await service.resolve(key) is None
await service.redeem((await service.issue_code(user_id))["code"], key)
assert await service.resolve(key) == user_id
assert await service.resolve("모르는-키") is None
async def test_만료된_코드는_안_먹는다(db_engine):
user_id = uuid.uuid4()
code = (await service.issue_code(user_id))["code"]
async with db_engine.begin() as c:
await c.execute(
text("UPDATE owner_kakao_links SET code_expires_at = now() - interval '1 minute' WHERE user_id=:u"),
{"u": user_id},
)
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(code, "kakao-key-4")
async def test_오입력_시도는_상한에서_끊긴다(db_engine):
"""짧은 코드(6자리)라 무차별 대입이 가능하다. 시도 수가 유일한 방어다."""
user_id = uuid.uuid4()
code = (await service.issue_code(user_id))["code"]
async with db_engine.begin() as c:
await c.execute(
text("UPDATE owner_kakao_links SET code_attempts = 3 WHERE user_id=:u"), {"u": user_id}
)
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(code, "kakao-key-5")
async def test_재발급은_행을_늘리지_않고_옛_코드를_죽인다(db_engine):
user_id = uuid.uuid4()
first = (await service.issue_code(user_id))["code"]
second = (await service.issue_code(user_id))["code"]
assert first != second
async with db_engine.begin() as c:
rows = (
await c.execute(
text("SELECT count(*) FROM owner_kakao_links WHERE user_id=:u AND deleted=false"),
{"u": user_id},
)
).scalar_one()
assert rows == 1
# ★ 옛 코드가 살아 있으면 둘 중 어느 것이 먹을지 화면이 말해 줄 수 없다.
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(first, "kakao-key-6")
assert await service.redeem(second, "kakao-key-6") == user_id
async def test_이미_연결된_사장님은_코드를_다시_받지_않는다(db_engine):
user_id = uuid.uuid4()
await service.redeem((await service.issue_code(user_id))["code"], "kakao-key-7")
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_ALREADY"):
await service.issue_code(user_id)
async def test_한_카카오_계정은_한_사장님에만_묶인다(db_engine):
"""없으면 같은 카톡 계정이 여러 사장님에 걸려 '어느 가게 이야기냐' 가 DB 에서 갈라진다."""
first, second, key = uuid.uuid4(), uuid.uuid4(), "kakao-key-8"
await service.redeem((await service.issue_code(first))["code"], key)
code = (await service.issue_code(second))["code"]
with pytest.raises(Exception): # 부분 유니크 위반 — 연결 자체가 성립하지 않는다
await service.redeem(code, key)
assert await service.resolve(key) == first
async def test_해제하면_그_발화자는_다시_아무도_아니다(db_engine):
user_id, key = uuid.uuid4(), "kakao-key-9"
await service.redeem((await service.issue_code(user_id))["code"], key)
await service.disconnect(user_id)
assert await service.resolve(key) is None
# 행은 남는다 — 지우면 누가 언제 연결했는지가 사라진다.
async with db_engine.begin() as c:
status = (
await c.execute(
text("SELECT status FROM owner_kakao_links WHERE user_id=:u"), {"u": user_id}
)
).scalar_one()
assert status == "REVOKED"
# 해제한 뒤에는 다시 연결할 수 있어야 한다.
await service.redeem((await service.issue_code(user_id))["code"], key)
assert await service.resolve(key) == user_id
async def test_해제할_연결이_없으면_거절한다(db_engine):
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_NOT_FOUND"):
await service.disconnect(uuid.uuid4())
async def test_상태는_코드_평문을_돌려주지_않는다(db_engine):
user_id = uuid.uuid4()
await service.issue_code(user_id)
snapshot = await service.state(user_id)
assert snapshot["status"] == "PENDING"
assert snapshot["code_expires_at"]
assert "code" not in snapshot
async def test_저장되는_것은_해시뿐이다(db_engine):
user_id = uuid.uuid4()
code = (await service.issue_code(user_id))["code"]
async with db_engine.begin() as c:
stored = (
await c.execute(
text("SELECT code_sha FROM owner_kakao_links WHERE user_id=:u"), {"u": user_id}
)
).scalar_one()
assert stored != code
assert len(stored) == 64
async def test_라우터는_로그인_없이_열리지_않는다(client):
for method, path in [
("get", "/v1/agent/kakao/link"),
("post", "/v1/agent/kakao/link/code"),
("post", "/v1/agent/kakao/link/disconnect"),
]:
res = await getattr(client, method)(path)
assert res.status_code in (401, 403), path
async def test_코드는_응답에서_한_번만_나가고_캐시되지_않는다(client, auth_headers):
h = await auth_headers("kakao-owner")
res = await client.post("/v1/agent/kakao/link/code", headers=h)
assert res.status_code == 200
assert res.json()["code"]
assert res.headers["Cache-Control"] == "no-store"
assert res.headers["Referrer-Policy"] == "no-referrer"
state = await client.get("/v1/agent/kakao/link", headers=h)
assert "code" not in state.json()
def test_코드에는_헷갈리는_글자가_없다():
"""잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다."""
assert not set("01OILl") & set(service._CODE_ALPHABET)
assert len(service._new_code()) == service._CODE_LENGTH

View File

@ -0,0 +1,146 @@
import {useCallback, useEffect, useState} from 'react';
import {Loader2, MessageCircle, Unlink} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {agentApi, type KakaoLinkCode, type KakaoLinkState} from './api';
/**
* 카카오톡 채널 연결 — **'내 사이트' 화면에 한 자리**.
*
* ★ 왜 사업장 화면이 아닌가
* 연결은 `user` 단위다(표도 그렇게 생겼다 — `owner_kakao_links`). 버튼이 사업장 안에
* 있으면 사장님은 **업장 수만큼 연결해야 하는 줄 안다.** 연결은 한 번이다.
* SocialConnectionCard 가 같은 이유로 여기 있다.
*
* ★ 코드는 화면에만 한 번 뜬다.
* 서버는 sha256 만 들고 있어서 **다시 보여줄 수 없다.** 새로고침하면 사라지므로
* "다시 받기" 를 항상 옆에 둔다 — 못 보여주는 것과 잃어버린 것은 다른 상태이고,
* 화면이 그 둘을 구별해 말해야 한다.
*
* ★ 준비 전에도 자리는 보여주고 버튼만 죽인다.
* 숨기면 기능이 없는 것처럼 보인다(2026-09-14, Threads 카드에서 실제로 겪었다).
*/
export function KakaoChannelCard() {
const [state, setState] = useState<KakaoLinkState | null>(null);
const [issued, setIssued] = useState<KakaoLinkCode | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try {
setState(await agentApi<KakaoLinkState>('/kakao/link'));
} catch {
// 연결 상태를 못 읽는 것은 사이트 목록을 못 보여줄 이유가 아니다 — 조용히 접는다.
setState(null);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// 상태를 아직 못 읽었을 때만 접는다(로그인 직후 한순간). '준비 안 됨' 과는 다르다.
if (!state) return null;
const ready = state.connection_enabled;
const linked = state.status === 'LINKED';
const channelUrl = issued?.channel_url || state.channel_url;
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError('');
try {
await fn();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '요청을 처리하지 못했습니다.');
} finally {
setBusy(false);
}
}
const issue = () =>
run(async () => {
setIssued(await agentApi<KakaoLinkCode>('/kakao/link/code', {}));
});
const disconnect = () =>
run(async () => {
setIssued(null);
await agentApi('/kakao/link/disconnect', {});
});
return (
<section className="mb-4 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-bold">카카오톡으로 관리 · 채널 연결</h2>
<p className="mt-1 text-xs text-muted-foreground">
{!ready
? '채널을 준비하고 있습니다. 열리면 여기서 연결합니다.'
: linked
? '연결되어 있습니다. 카카오톡에서 가게 정보를 고치고 사이트를 발행할 수 있습니다.'
: '연결해 두면 카카오톡 대화창에서 가게 정보를 고치고 사이트를 발행할 수 있습니다.'}
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{!linked && (
<Button size="sm" variant="primary" disabled={busy || !ready} onClick={issue}>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MessageCircle className="size-4" />
)}
<span>{state.status === 'PENDING' ? '코드 다시 받기' : '카카오톡 연결'}</span>
</Button>
)}
{(linked || state.status === 'PENDING') && (
<Button size="sm" variant="ghost" disabled={busy} onClick={disconnect}>
<Unlink className="size-4" />
<span>{linked ? '연결 해제' : '연결 취소'}</span>
</Button>
)}
</div>
</div>
{issued && !linked && (
<div className="mt-3 border-t border-border pt-3">
<p className="text-xs font-bold">카카오톡 채널에 이 코드를 보내 주세요</p>
<p className="mt-2 font-mono text-2xl font-bold tracking-[0.3em]">{issued.code}</p>
<p className="mt-2 text-xs text-muted-foreground">
{new Date(issued.expires_at).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
})}
까지 쓸 수 있습니다. 화면을 나가면 코드를 다시 볼 수 없어요 — 그때는 다시 받으면 됩니다.
</p>
{channelUrl && (
<p className="mt-2 text-xs">
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="font-bold underline underline-offset-4"
>
카카오톡 채널 열기 →
</a>
</p>
)}
</div>
)}
{/* 코드를 냈는데 화면을 새로 열어 코드가 사라진 경우. '기다리는 중' 을 숨기지 않는다. */}
{!issued && state.status === 'PENDING' && (
<p className="mt-3 text-xs text-muted-foreground">
보낸 코드를 기다리고 있습니다. 코드를 잃어버렸다면 다시 받아 주세요.
</p>
)}
{error && (
<p role="alert" className="mt-3 text-xs text-destructive">
{error}
</p>
)}
</section>
);
}

View File

@ -0,0 +1,46 @@
import {getAccessToken} from '@/api';
/**
* 사장님 에이전트 — 카카오톡 채널 연결.
*
* ★ social 과 파일을 가른 이유는 도메인이 다르기 때문이다. SNS 게재는 되돌릴 수 없는
* 대외 발화이고, 이건 사장님이 자기 사이트를 고치는 창구다. 한 사전에 섞으면
* 에러 문구가 어느 기능의 것인지 화면에서 구별되지 않는다.
*/
export type KakaoLinkState = {
connection_enabled: boolean;
channel_url: string;
status: 'PENDING' | 'LINKED' | 'REVOKED' | null;
linked_at: string | null;
code_expires_at: string | null;
};
export type KakaoLinkCode = {code: string; expires_at: string; channel_url: string};
const base = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800';
const messages: Record<string, string> = {
KAKAO_LINK_DISABLED: '카카오톡 채널을 준비하고 있습니다. 준비되면 여기서 연결할 수 있습니다.',
KAKAO_LINK_ALREADY: '이미 연결되어 있습니다.',
KAKAO_LINK_CODE_INVALID: '코드가 맞지 않거나 시간이 지났습니다. 새 코드를 받아 주세요.',
KAKAO_LINK_NOT_FOUND: '연결된 카카오톡 계정이 없습니다.',
KAKAO_LINK_TAKEN: '그 카카오톡 계정은 다른 계정에 이미 연결되어 있습니다.',
};
export async function agentApi<T>(path: string, data?: unknown): Promise<T> {
const token = getAccessToken();
const response = await fetch(`${base}/v1/agent${path}`, {
method: data === undefined ? 'GET' : 'POST',
credentials: 'include',
// 연결 코드가 오가는 요청이다 — 리퍼러로 새거나 캐시에 남지 않게 한다.
referrerPolicy: 'no-referrer',
cache: 'no-store',
headers: {'Content-Type': 'application/json', ...(token ? {Authorization: `Bearer ${token}`} : {})},
body: data === undefined ? undefined : JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
throw new Error(messages[result.detail] ?? '요청을 처리하지 못했습니다. 잠시 후 다시 확인해 주세요.');
}
return result as T;
}

View File

@ -1,4 +1,5 @@
import {SocialConnectionCard} from '@/features/social/SocialConnectionCard';
import {KakaoChannelCard} from '@/features/agent/KakaoChannelCard';
import {SocialConnectionNotice} from '@/features/social/SocialConnectionNotice';
import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router';
@ -259,6 +260,8 @@ export function SitesPage() {
<SocialConnectionNotice />
{/* 연결은 사업장이 아니라 사람 단위다 — 목록 위에 한 자리만 둔다(SocialConnectionCard 주석). */}
<SocialConnectionCard />
{/* 카카오톡 연결도 사람 단위다 — 같은 자리에 나란히 둔다(KakaoChannelCard 주석). */}
<KakaoChannelCard />
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />