[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:
parent
94ae4a825a
commit
16b17bc91c
@ -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(웹 애플리케이션)
|
||||
|
||||
11
AGENTS.md
11
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절이 보류한 것은
|
||||
|
||||
102
docs/AGENT.md
Normal file
102
docs/AGENT.md
Normal 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` 가 크롤러에 걸어 둔 문과 같은 문이다.
|
||||
@ -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건
|
||||
|
||||
**한 일**
|
||||
|
||||
@ -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(),
|
||||
|
||||
41
postgres-init/migrations/0021_owner_kakao_links.sql
Normal file
41
postgres-init/migrations/0021_owner_kakao_links.sql
Normal 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';
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
38
solution/backend/config/agent_config.py
Normal file
38
solution/backend/config/agent_config.py
Normal 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 ""
|
||||
@ -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)
|
||||
|
||||
51
solution/backend/router/v1/agent/kakao.py
Normal file
51
solution/backend/router/v1/agent/kakao.py
Normal 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}
|
||||
201
solution/backend/services/kakao_link_service.py
Normal file
201
solution/backend/services/kakao_link_service.py
Normal 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)
|
||||
185
solution/backend/tests/test_kakao_link.py
Normal file
185
solution/backend/tests/test_kakao_link.py
Normal 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
|
||||
146
solution/frontend/src/features/agent/KakaoChannelCard.tsx
Normal file
146
solution/frontend/src/features/agent/KakaoChannelCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
solution/frontend/src/features/agent/api.ts
Normal file
46
solution/frontend/src/features/agent/api.ts
Normal 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;
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user