[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" />