From 16b17bc91c02fd74e6c2a417ba7e0b01598b59cc Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 21 Sep 2026 16:01:58 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend,frontend:=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=ED=86=A1=20=EC=B1=84=EB=84=90=20=EC=8B=A0?= =?UTF-8?q?=EC=9B=90=20=EC=97=B0=EA=B2=B0=20=E2=80=94=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=201=EB=8B=A8=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 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) --- .env.example | 9 + AGENTS.md | 11 + docs/AGENT.md | 102 +++++++++ docs/DEVLOG.md | 31 +++ postgres-init/init-data/init.sql | 34 +++ .../migrations/0021_owner_kakao_links.sql | 41 ++++ .../backend/common/database/model/models.py | 26 +++ solution/backend/common/enums.py | 18 ++ solution/backend/config/agent_config.py | 38 ++++ solution/backend/router/router.py | 2 + solution/backend/router/v1/agent/kakao.py | 51 +++++ .../backend/services/kakao_link_service.py | 201 ++++++++++++++++++ solution/backend/tests/test_kakao_link.py | 185 ++++++++++++++++ .../src/features/agent/KakaoChannelCard.tsx | 146 +++++++++++++ solution/frontend/src/features/agent/api.ts | 46 ++++ solution/frontend/src/pages/SitesPage.tsx | 3 + 16 files changed, 944 insertions(+) create mode 100644 docs/AGENT.md create mode 100644 postgres-init/migrations/0021_owner_kakao_links.sql create mode 100644 solution/backend/config/agent_config.py create mode 100644 solution/backend/router/v1/agent/kakao.py create mode 100644 solution/backend/services/kakao_link_service.py create mode 100644 solution/backend/tests/test_kakao_link.py create mode 100644 solution/frontend/src/features/agent/KakaoChannelCard.tsx create mode 100644 solution/frontend/src/features/agent/api.ts diff --git a/.env.example b/.env.example index f2ab621..557ed5d 100644 --- a/.env.example +++ b/.env.example @@ -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(웹 애플리케이션) diff --git a/AGENTS.md b/AGENTS.md index 6f4a50e..ab7a25d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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절이 보류한 것은 diff --git a/docs/AGENT.md b/docs/AGENT.md new file mode 100644 index 0000000..ff29772 --- /dev/null +++ b/docs/AGENT.md @@ -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` 가 크롤러에 걸어 둔 문과 같은 문이다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 363de12..df1ed95 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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건 **한 일** diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index e525167..27fa6a6 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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(), diff --git a/postgres-init/migrations/0021_owner_kakao_links.sql b/postgres-init/migrations/0021_owner_kakao_links.sql new file mode 100644 index 0000000..705e0a4 --- /dev/null +++ b/postgres-init/migrations/0021_owner_kakao_links.sql @@ -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'; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index e033f6b..51d8868 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -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) diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 5489755..2be71f0 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -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" diff --git a/solution/backend/config/agent_config.py b/solution/backend/config/agent_config.py new file mode 100644 index 0000000..65c792d --- /dev/null +++ b/solution/backend/config/agent_config.py @@ -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 "" diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index 40bbf60..2b2e8e2 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -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) diff --git a/solution/backend/router/v1/agent/kakao.py b/solution/backend/router/v1/agent/kakao.py new file mode 100644 index 0000000..727cd2a --- /dev/null +++ b/solution/backend/router/v1/agent/kakao.py @@ -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} diff --git a/solution/backend/services/kakao_link_service.py b/solution/backend/services/kakao_link_service.py new file mode 100644 index 0000000..211343c --- /dev/null +++ b/solution/backend/services/kakao_link_service.py @@ -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) diff --git a/solution/backend/tests/test_kakao_link.py b/solution/backend/tests/test_kakao_link.py new file mode 100644 index 0000000..d26fd5e --- /dev/null +++ b/solution/backend/tests/test_kakao_link.py @@ -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 diff --git a/solution/frontend/src/features/agent/KakaoChannelCard.tsx b/solution/frontend/src/features/agent/KakaoChannelCard.tsx new file mode 100644 index 0000000..41dd11c --- /dev/null +++ b/solution/frontend/src/features/agent/KakaoChannelCard.tsx @@ -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(null); + const [issued, setIssued] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + try { + setState(await agentApi('/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) { + 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('/kakao/link/code', {})); + }); + + const disconnect = () => + run(async () => { + setIssued(null); + await agentApi('/kakao/link/disconnect', {}); + }); + + return ( +
+
+
+

카카오톡으로 관리 · 채널 연결

+

+ {!ready + ? '채널을 준비하고 있습니다. 열리면 여기서 연결합니다.' + : linked + ? '연결되어 있습니다. 카카오톡에서 가게 정보를 고치고 사이트를 발행할 수 있습니다.' + : '연결해 두면 카카오톡 대화창에서 가게 정보를 고치고 사이트를 발행할 수 있습니다.'} +

+
+ +
+ {!linked && ( + + )} + {(linked || state.status === 'PENDING') && ( + + )} +
+
+ + {issued && !linked && ( +
+

카카오톡 채널에 이 코드를 보내 주세요

+

{issued.code}

+

+ {new Date(issued.expires_at).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + })} + 까지 쓸 수 있습니다. 화면을 나가면 코드를 다시 볼 수 없어요 — 그때는 다시 받으면 됩니다. +

+ {channelUrl && ( +

+ + 카카오톡 채널 열기 → + +

+ )} +
+ )} + + {/* 코드를 냈는데 화면을 새로 열어 코드가 사라진 경우. '기다리는 중' 을 숨기지 않는다. */} + {!issued && state.status === 'PENDING' && ( +

+ 보낸 코드를 기다리고 있습니다. 코드를 잃어버렸다면 다시 받아 주세요. +

+ )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/solution/frontend/src/features/agent/api.ts b/solution/frontend/src/features/agent/api.ts new file mode 100644 index 0000000..c5afd7d --- /dev/null +++ b/solution/frontend/src/features/agent/api.ts @@ -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 = { + KAKAO_LINK_DISABLED: '카카오톡 채널을 준비하고 있습니다. 준비되면 여기서 연결할 수 있습니다.', + KAKAO_LINK_ALREADY: '이미 연결되어 있습니다.', + KAKAO_LINK_CODE_INVALID: '코드가 맞지 않거나 시간이 지났습니다. 새 코드를 받아 주세요.', + KAKAO_LINK_NOT_FOUND: '연결된 카카오톡 계정이 없습니다.', + KAKAO_LINK_TAKEN: '그 카카오톡 계정은 다른 계정에 이미 연결되어 있습니다.', +}; + +export async function agentApi(path: string, data?: unknown): Promise { + 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; +} diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index c4a7167..2fea0fd 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -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() { {/* 연결은 사업장이 아니라 사람 단위다 — 목록 위에 한 자리만 둔다(SocialConnectionCard 주석). */} + {/* 카카오톡 연결도 사람 단위다 — 같은 자리에 나란히 둔다(KakaoChannelCard 주석). */} + {isLoading && (