[feat] solution,deploy: 카카오 로그인을 인가 코드 교환으로 — 시크릿을 서버에 가둔다

콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 Redirect URI 가
프론트 경로(/auth/kakao/callback)인데, 코드는 "프론트가 액세스 토큰을 들고 온다" 는
전제였다 — 시크릿을 번들에 구울 수 없으므로 그 흐름으로는 토큰을 받을 수 없다.

- services/external/kakao_identity: access_token 검증 → **인가 코드 교환**으로 교체.
  ★ 우리 REST 키·시크릿으로 교환한 토큰이라 정의상 우리 앱 것이다 — 구글의 aud 대조에
    해당하는 검사(KAKAO_LOGIN_APP_ID)가 구조적으로 필요 없어져 설정 칸째로 지웠다.
  ★ 로그인 앱과 챗봇 앱이 다르면 로그인은 되는데 채널 매칭만 조용히 안 된다 —
    REST 키가 어긋나면 경고 로그를 남긴다.
  ★ 미인증 이메일은 신원으로 쓰지 않는다(is_email_valid·is_email_verified 둘 다). 이 값으로
    auth_service 가 다른 수단 가입과의 충돌을 판정한다.
- config/config_models: KAKAO_LOGIN_APP_ID → REST_API_KEY · CLIENT_SECRET · REDIRECT_URI
- router/v1/auth: Req_KakaoLogin {access_token} → {code, redirect_uri}
- frontend: lib/kakaoIdentity(인가 URL · sessionStorage state) · KakaoSignInButton ·
  pages/KakaoCallbackPage(코드는 1회용이라 ref 로 한 번만 보낸다) · routes
  ★ 로그인 폼에서 카카오를 구글 위에 둔다 — 이 수단으로 들어와야 6자리 코드 없이 채널이 이어진다
- compose · nginx/Dockerfile: REST 키·Redirect URI 만 프론트로 흘려보낸다. 시크릿은 안 나간다
- 어드민 키는 저장하지 않는다 — 탈퇴까지 되는 키인데 로그인에는 쓰이지 않는다
- api/generated: orval 재생성. 카카오 외에도 오래 밀려 있던 문구·agent 경로가 함께 따라왔다

테스트 8건 추가(tests/test_kakao_identity.py), 관련 85 passed
(test_auth::test_google_login_is_off_when_client_id_is_empty 는 기존 실패)
lint(react-router typegen + tsc + eslint) 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-30 16:51:05 +09:00
parent ca0bea77a7
commit 3253166622
84 changed files with 1757 additions and 259 deletions

View File

@ -120,10 +120,20 @@ KAKAO_LINK_MAX_ATTEMPTS=5
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다. # VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site # ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
# 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다). # 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다).
# 카카오 개발자 콘솔 > 내 애플리케이션 > 앱 키(REST API 키) · 카카오 로그인 > 보안(client_secret)
# ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 # ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은
# 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요). # 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요).
# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. # 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. 그래서 아래 값은 KAKAO_BOT_REST_API_KEY
KAKAO_LOGIN_APP_ID= # 와 같은 값이고, 어긋나면 서버가 로그에 경고를 남긴다(services/external/kakao_identity.py).
KAKAO_LOGIN_REST_API_KEY=
# ★ 이건 비밀이다. 그래서 프론트가 토큰을 받는 구조를 못 쓰고 **인가 코드를 서버가 교환**한다.
KAKAO_LOGIN_CLIENT_SECRET=
# ★ 콘솔의 [카카오 로그인 > Redirect URI] 에 등록한 값과 **글자 하나까지** 같아야 한다.
# 다르면 KOE006 이고 화면에는 그냥 "로그인 실패" 로만 보인다.
# ★ 프론트(VITE_KAKAO_REDIRECT_URI)와 같은 값이어야 한다 — compose 가 흘려보낸다.
# 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
KAKAO_LOGIN_REDIRECT_URI=
# ★ 어드민 키는 적지 않는다. 계정을 지우는 것까지 되는 키인데 로그인에는 쓰이지 않는다.
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=

View File

@ -24,6 +24,12 @@ x-common-env: &common-env
# ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의 # ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. # 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# ★ 카카오 로그인은 시크릿 때문에 **서버가 인가 코드를 교환**한다 — 프론트에는 REST 키와
# 리다이렉트 주소만 간다(둘 다 공개값이다). 시크릿은 여기서 밖으로 나가지 않는다.
# ★★ KAKAO_BOT_REST_API_KEY 와 **같은 값**이어야 카톡 채널 발화자와 계정이 이어진다.
KAKAO_LOGIN_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-}
KAKAO_LOGIN_CLIENT_SECRET: ${KAKAO_LOGIN_CLIENT_SECRET:-}
KAKAO_LOGIN_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-}
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다. # ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가 # ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다). # 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
@ -190,6 +196,9 @@ services:
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처). # 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# 인가 URL 을 만드는 데 쓴다. 시크릿은 넘기지 않는다 — 교환은 백엔드가 한다.
VITE_KAKAO_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-}
VITE_KAKAO_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-}
volumes: volumes:
- ./package.json:/app/package.json - ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json - ./package-lock.json:/app/package-lock.json
@ -260,6 +269,9 @@ services:
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다. # 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# 비어 있으면 카카오 로그인 버튼이 안 뜬다. 인가 URL 만 만든다 — 시크릿은 여기 없다.
VITE_KAKAO_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-}
VITE_KAKAO_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-}
# ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다. # ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다.
# 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다. # 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다.
image: o2o-web4ai-solution-site image: o2o-web4ai-solution-site

View File

@ -30,10 +30,17 @@ ARG VITE_SITE_PREVIEW_URL
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와 # 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다). # 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
ARG VITE_GOOGLE_CLIENT_ID ARG VITE_GOOGLE_CLIENT_ID
# 카카오 REST API 키와 Redirect URI. 둘 다 비밀이 아니다 — 인가 URL 에 그대로 실려 나간다.
# ★ 클라이언트 시크릿은 여기 없다. 인가 코드를 토큰으로 바꾸는 일은 **백엔드가 한다** —
# 시크릿이 번들에 구워지면 누구나 우리 앱 이름으로 토큰을 받을 수 있다.
ARG VITE_KAKAO_REST_API_KEY
ARG VITE_KAKAO_REDIRECT_URI
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \ VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \
VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID \
VITE_KAKAO_REST_API_KEY=$VITE_KAKAO_REST_API_KEY \
VITE_KAKAO_REDIRECT_URI=$VITE_KAKAO_REDIRECT_URI
# ★ VITE_AUTO_LOGIN_ID·PW 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는 # ★ VITE_AUTO_LOGIN_ID·PW 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는
# 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나 # 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나
# JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev, # JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev,

View File

@ -160,7 +160,7 @@ class AuthProvider(CodeEnum):
LOCAL = 1 # id/pw LOCAL = 1 # id/pw
GOOGLE = 2 # 구글 ID 토큰 GOOGLE = 2 # 구글 ID 토큰
KAKAO = 3 # 카카오 액세스 토큰 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다 KAKAO = 3 # 카카오 인가 코드 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다
class CompanyStatus(CodeEnum): class CompanyStatus(CodeEnum):

View File

@ -103,18 +103,29 @@ class GoogleOAuthConfig(BaseSettings):
class KakaoLoginConfig(BaseSettings): class KakaoLoginConfig(BaseSettings):
"""카카오 로그인. app_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. """카카오 로그인. 값이 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다.
★★ **챗봇에 물린 앱과 같은 앱이어야 한다.** 회원번호(`id`)가 챗봇 웹훅의 ★★ **챗봇에 물린 앱과 같은 앱이어야 한다.** 회원번호(`id`)가 챗봇 웹훅의
`user.properties.appUserId` 와 같은 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 `user.properties.appUserId` 와 같은 값이라, 그래야 카톡 채널 발화자와 로그인 계정이
바로 이어진다(6자리 코드 절차가 필요 없어진다). 앱이 다르면 **로그인은 되는데 매칭만 바로 이어진다(6자리 코드 절차가 필요 없어진다). 앱이 다르면 **로그인은 되는데 매칭만
조용히 안 된다** — 증상이 안 보이는 종류라 여기 적어 둔다. 조용히 안 된다** — 증상이 안 보이는 종류라, `kakao_identity` 가 값이 어긋나면 경고를 남긴다.
★ app_id 는 비밀이 아니다(토큰 응답에 실려 온다). 서버가 이 값을 갖는 이유는 숨기려는 게
아니라 **발급처 대조** 때문이다 — 구글의 `aud` 검사와 같은 자리다.""" ★ 그래서 `rest_api_key` 는 `agent_config.KAKAO_BOT_REST_API_KEY` 와 **같은 값**이다.
AGENTS.md 의 "두 곳에 같은 값을 적지 않는다" 를 여기서만 깨는 이유: 챗봇 설정과 로그인
설정은 수명이 다르고(한쪽만 갈아 끼우는 일이 실제로 생긴다), 한 칸으로 합치면 어긋났을 때
**어긋났다는 사실 자체를 볼 수 없다.** 따로 두고 대조해 경고를 내는 쪽을 택했다.
★ `client_secret` 은 비밀이다. 그래서 프론트가 토큰을 받아 오는 방식을 못 쓰고
**인가 코드를 서버가 교환**한다 — 시크릿을 브라우저에 둘 수 없기 때문이다."""
model_config = _BASE model_config = _BASE
app_id: str = Field("", validation_alias="KAKAO_LOGIN_APP_ID") rest_api_key: str = Field("", validation_alias="KAKAO_LOGIN_REST_API_KEY")
client_secret: str = Field("", validation_alias="KAKAO_LOGIN_CLIENT_SECRET")
# ★ 콘솔에 등록한 값과 **글자 하나까지** 같아야 한다. 다르면 KOE006 이고 화면에는 그냥
# "로그인 실패" 로만 보인다. 프론트의 인가 요청과 서버의 교환이 같은 값을 써야 하므로
# compose 가 이 값을 VITE_KAKAO_REDIRECT_URI 로 흘려보낸다.
redirect_uri: str = Field("", validation_alias="KAKAO_LOGIN_REDIRECT_URI")
class ExternalApiConfig(BaseSettings): class ExternalApiConfig(BaseSettings):

View File

@ -44,7 +44,8 @@ async def google_login(req: Req_GoogleLogin, service: AuthService = Depends()):
path="/kakao", path="/kakao",
response_model=Res_Login, response_model=Res_Login,
summary="카카오 로그인", summary="카카오 로그인",
description="카카오 **액세스 토큰**을 카카오에 되물어 검증하고 JWT 를 발급한다. 처음 온 계정은 그 자리에서 만든다. " description="카카오 **인가 코드**를 서버가 토큰으로 교환해 신원을 확인하고 JWT 를 발급한다. "
"처음 온 계정은 그 자리에서 만든다. ★ 코드는 1회용이라 같은 값으로 두 번 부르면 실패한다. "
"★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, " "★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, "
"그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md).", "그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md).",
) )

View File

@ -32,10 +32,16 @@ class Req_GoogleLogin(AuthProtocol):
class Req_KakaoLogin(AuthProtocol): class Req_KakaoLogin(AuthProtocol):
"""카카오 로그인. """카카오 로그인.
★ 구글은 ID 토큰(credential)을 우리가 직접 검증하지만, 카카오는 **액세스 토큰**을 ★ 구글은 브라우저가 받은 ID 토큰(credential)을 우리가 검증하지만, 카카오는 **인가 코드**를
카카오에 되물어 확인한다 — 그래서 필드 이름이 다르다.""" 받아 서버가 토큰으로 바꾼다 — 클라이언트 시크릿을 브라우저에 둘 수 없기 때문이다.
그래서 필드 이름이 다르다.
access_token: str = "" ★ `redirect_uri` 는 프론트가 인가를 요청할 때 쓴 값이다. 카카오가 교환 시점에 이 값을
대조하므로 **글자 하나까지 같아야** 한다. 비우면 서버 설정값을 쓴다 — 한 벌로 쓰는
평소에는 보낼 필요가 없고, 로컬처럼 주소가 다른 환경에서만 채운다."""
code: str = ""
redirect_uri: str = ""
class Res_Login(Res_WebPacketProtocol): class Res_Login(Res_WebPacketProtocol):

View File

@ -29,7 +29,7 @@ from services.external.kakao_identity import (
KakaoAccount, KakaoAccount,
KakaoNotConfigured, KakaoNotConfigured,
KakaoTokenInvalid, KakaoTokenInvalid,
verify_access_token as verify_kakao_token, verify_code as verify_kakao_code,
) )
from services.external.google_identity import ( from services.external.google_identity import (
GoogleAccount, GoogleAccount,
@ -261,7 +261,7 @@ class AuthService:
return await self._finish_login(user) return await self._finish_login(user)
async def kakao_login(self, req: Req_KakaoLogin) -> Res_Login: async def kakao_login(self, req: Req_KakaoLogin) -> Res_Login:
"""카카오 액세스 토큰 → 우리 세션. """카카오 인가 코드 → 우리 세션.
★ google_login 과 **같은 세 갈래**다(기존 계정 → 이메일 충돌 → 신규). 규칙을 한 벌로 ★ google_login 과 **같은 세 갈래**다(기존 계정 → 이메일 충돌 → 신규). 규칙을 한 벌로
유지하려고 모양을 맞췄다 — 여기만 다르게 두면 "어느 쪽이 맞나" 를 매번 되짚게 된다. 유지하려고 모양을 맞췄다 — 여기만 다르게 두면 "어느 쪽이 맞나" 를 매번 되짚게 된다.
@ -271,7 +271,7 @@ class AuthService:
res = Res_Login() res = Res_Login()
try: try:
account: KakaoAccount = await verify_kakao_token(req.access_token) account: KakaoAccount = await verify_kakao_code(req.code, req.redirect_uri)
except KakaoNotConfigured: except KakaoNotConfigured:
res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED) res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED)
return res return res

View File

@ -1,42 +1,59 @@
"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다. """카카오 로그인 — 인가 코드를 우리 서버가 토큰으로 바꾸고, 그 토큰으로 신원을 확인한다.
★ 구글(google_identity)과 검증 방식이 다르다. 구글은 ID 토큰을 **우리가 직접** 서명·수신자까지 ★ 구글과 흐름이 다르다. 구글은 프론트가 받은 ID 토큰을 우리가 검증하지만(GIS),
뜯어보지만, 카카오는 액세스 토큰을 카카오에 되물어 확인한다(`/v1/user/access_token_info`). 카카오는 **클라이언트 시크릿이 있으면 토큰 교환을 서버가 해야 한다** — 시크릿을 브라우저에
그 응답의 `app_id` 가 우리 앱인지 대조하는 것이 구글의 `aud` 검사에 해당한다 — 둘 수 없기 때문이다. 그래서 프론트는 `code` 만 넘기고 교환은 여기서 한다.
**이 검사가 유일하게 "남의 앱에 발급된 진짜 카카오 토큰" 을 막는다.**
★★ 여기서 얻는 회원번호(`id`)가 **챗봇 웹훅의 `user.properties.appUserId` 와 같은 값**이다 ★★ 그래서 "남의 앱 토큰" 문제가 구조적으로 사라진다. 우리 REST 키·시크릿으로 교환한
(카카오 공식 문서: "앱 키가 정상적으로 등록된 경우, 카카오 로그인으로 받는 값과 동일"). 토큰이므로 정의상 우리 앱 것이다 — 구글에서 `aud` 를 대조하던 자리가 여기서는 필요 없다.
그래서 봇에 물린 앱과 **같은 앱**이어야 채널 매칭이 된다 — 앱이 다르면 로그인은 되는데
매칭만 조용히 안 된다. ★★ **로그인 앱과 챗봇 앱이 같아야 한다.** 회원번호(`id`)가 챗봇 웹훅의
`user.properties.appUserId` 와 같은 값이라, 같은 앱일 때만 채널 발화자와 이어진다.
다르면 **로그인은 되는데 매칭만 조용히 안 된다** — 그래서 아래에서 키가 어긋나면 경고를 남긴다.
""" """
from dataclasses import dataclass from dataclasses import dataclass
import contextlib
import httpx import httpx
from common.logger import LOG from common.logger import LOG
from config import agent_config
from config.server_configs import kakao_login_config from config.server_configs import kakao_login_config
_TOKEN_INFO_URL = "https://kapi.kakao.com/v1/user/access_token_info" _TOKEN_URL = "https://kauth.kakao.com/oauth/token"
_USER_ME_URL = "https://kapi.kakao.com/v2/user/me" _USER_ME_URL = "https://kapi.kakao.com/v2/user/me"
_HTTP_TIMEOUT_SEC = 5.0 _HTTP_TIMEOUT_SEC = 7.0
@contextlib.asynccontextmanager
async def _session(client: httpx.AsyncClient | None):
"""★ `kakao_event.send` 는 클라이언트를 호출부가 넘기게 했지만 여기서는 선택이다.
저쪽은 한 요청에 여러 사장님에게 보내는 자리라 연결을 재사용해야 하고, 로그인은 사람 한 명이
한 번 누르는 일이다 — 호출부(auth_service)에 httpx 를 끌고 들어가지 않는 쪽을 택했다.
테스트가 MockTransport 를 물릴 수 있도록 인자는 열어 둔다."""
if client is not None:
yield client
return
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as owned:
yield owned
class KakaoNotConfigured(RuntimeError): class KakaoNotConfigured(RuntimeError):
"""KAKAO_LOGIN_APP_ID 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다).""" """REST 키·리다이렉트 URI 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다)."""
class KakaoTokenInvalid(RuntimeError): class KakaoTokenInvalid(RuntimeError):
"""만료·위조이거나 **남의 앱에 발급된** 토큰이다.""" """인가 코드가 만료·재사용됐거나 리다이렉트 URI 가 콘솔 등록값과 다르다."""
@dataclass @dataclass
class KakaoAccount: class KakaoAccount:
"""토큰으로 확인한 신원. """토큰으로 확인한 신원.
★ uid 가 판정 키다. 이메일은 동의 항목이라 **없을 수 있고** 바뀔 수도 있다 — ★ uid 가 판정 키다. 이메일은 **동의 항목이라 없을 수 있고** 바뀔 수도 있다 —
구글에서 sub 를 쓰는 것과 같은 이유다.""" 구글에서 sub 를 쓰는 것과 같은 이유다."""
uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다. uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다.
@ -45,49 +62,95 @@ class KakaoAccount:
def is_configured() -> bool: def is_configured() -> bool:
return bool(kakao_login_config.app_id) return bool(kakao_login_config.rest_api_key and kakao_login_config.redirect_uri)
async def verify_access_token(access_token: str) -> KakaoAccount: def _warn_if_not_same_app() -> None:
"""액세스 토큰 → 신원. 실패는 두 예외 중 하나로만 나간다.""" """★ 챗봇과 다른 앱이면 로그인은 되는데 채널 매칭만 조용히 안 된다. 증상이 안 보이는
종류라, 값이 어긋나면 로그로라도 남긴다."""
bot_key = agent_config.get("KAKAO_BOT_REST_API_KEY")
if bot_key and bot_key != kakao_login_config.rest_api_key:
LOG.w("[kakao-login] 로그인 앱과 챗봇 앱의 REST 키가 다르다 — 채널 자동 매칭이 안 된다")
async def exchange_code(code: str, redirect_uri: str = "", *,
client: httpx.AsyncClient | None = None) -> str:
"""인가 코드 → 액세스 토큰.
★ 리다이렉트 URI 는 **인가를 요청할 때 쓴 값과 글자 하나까지 같아야** 한다. 카카오가
그걸로 대조하기 때문이다 — 다르면 `KOE006` 으로 떨어지고, 화면에는 그냥 로그인 실패로만
보인다. 그래서 설정값 하나를 단일 출처로 두고 호출측이 덮어쓸 수 있게만 했다."""
if not is_configured(): if not is_configured():
raise KakaoNotConfigured("KAKAO_LOGIN_APP_ID 가 설정되지 않았다") raise KakaoNotConfigured("KAKAO_LOGIN_REST_API_KEY / REDIRECT_URI 가 설정되지 않았다")
if not access_token: if not code:
raise KakaoTokenInvalid("빈 토큰") raise KakaoTokenInvalid("빈 인가 코드")
_warn_if_not_same_app()
headers = {"Authorization": f"Bearer {access_token}"} form = {
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: "grant_type": "authorization_code",
"client_id": kakao_login_config.rest_api_key,
"redirect_uri": redirect_uri or kakao_login_config.redirect_uri,
"code": code,
}
# 콘솔에서 시크릿을 켰으면 필수다. 안 켰으면 보내도 무시된다 — 양쪽 다 도는 쪽으로 둔다.
if kakao_login_config.client_secret:
form["client_secret"] = kakao_login_config.client_secret
async with _session(client) as http:
try: try:
info = await client.get(_TOKEN_INFO_URL, headers=headers) res = await http.post(_TOKEN_URL, data=form)
except httpx.HTTPError as ex: except httpx.HTTPError as ex:
# 카카오에 못 닿은 것과 토큰이 틀린 것은 다르다 — 로그로 가른다. LOG.w(f"[kakao-login] 토큰 교환 실패: {type(ex).__name__}")
LOG.w(f"[kakao-login] 토큰 확인 실패: {type(ex).__name__}") raise KakaoTokenInvalid("토큰 교환 실패") from ex
raise KakaoTokenInvalid("토큰 확인 실패") from ex
if info.status_code != 200:
raise KakaoTokenInvalid(f"토큰이 유효하지 않다({info.status_code})")
data = info.json() if res.status_code != 200:
# ★ 구글의 aud 검사에 해당한다. 이게 없으면 남의 앱 토큰으로 우리 계정이 된다. # ★ 응답 본문에 code·시크릿이 실려 있을 수 있어 그대로 남기지 않는다. 카카오가 주는
if str(data.get("app_id") or "") != str(kakao_login_config.app_id): # error 코드만 남긴다 — 원인 대부분이 KOE006(리다이렉트 불일치)·코드 재사용이다.
LOG.w("[kakao-login] 다른 앱에 발급된 토큰 — 거절")
raise KakaoTokenInvalid("우리 앱의 토큰이 아니다")
uid = str(data.get("id") or "")
if not uid:
raise KakaoTokenInvalid("회원번호가 없다")
# 이름·이메일은 동의 항목이라 못 받을 수 있다. 못 받아도 로그인은 되어야 한다 —
# 판정 키는 회원번호뿐이고 나머지는 화면에 보여 줄 값일 뿐이다.
email = name = ""
try: try:
me = await client.get(_USER_ME_URL, headers=headers) reason = (res.json() or {}).get("error") or res.status_code
if me.status_code == 200: except ValueError:
body = me.json() reason = res.status_code
account = body.get("kakao_account") or {} LOG.w(f"[kakao-login] 토큰 교환 거절: {reason}")
email = (account.get("email") or "") if account.get("is_email_valid") is not False else "" raise KakaoTokenInvalid(f"인가 코드로 토큰을 받지 못했다({reason})")
name = ((account.get("profile") or {}).get("nickname")
or (body.get("properties") or {}).get("nickname") or "")
except httpx.HTTPError as ex:
LOG.w(f"[kakao-login] 프로필 조회 실패(로그인은 계속): {type(ex).__name__}")
token = (res.json() or {}).get("access_token") or ""
if not token:
raise KakaoTokenInvalid("액세스 토큰이 없다")
return token
async def fetch_account(access_token: str, *, client: httpx.AsyncClient | None = None) -> KakaoAccount:
"""액세스 토큰 → 신원. 회원번호만 필수고 나머지는 못 받아도 로그인은 된다."""
async with _session(client) as http:
try:
res = await http.get(_USER_ME_URL, headers={"Authorization": f"Bearer {access_token}"})
except httpx.HTTPError as ex:
LOG.w(f"[kakao-login] 사용자 조회 실패: {type(ex).__name__}")
raise KakaoTokenInvalid("사용자 조회 실패") from ex
if res.status_code != 200:
raise KakaoTokenInvalid(f"사용자 조회 거절({res.status_code})")
body = res.json() or {}
uid = str(body.get("id") or "")
if not uid:
raise KakaoTokenInvalid("회원번호가 없다")
# 이름·이메일은 동의 항목이다. 못 받아도 계정은 만들어야 한다 — 판정 키는 회원번호뿐이고
# 나머지는 화면에 보여 줄 값일 뿐이다.
account = body.get("kakao_account") or {}
# ★ 확인되지 않은 이메일은 신원으로 쓰지 않는다 — 남의 주소를 적어 둔 계정일 수 있고,
# `auth_service` 가 이 값으로 **다른 수단 가입과의 충돌**을 판정한다. 구글에서
# `email_verified` 를 요구하는 그 자리다. 카카오는 두 칸으로 나눠 준다(사용 가능·인증됨).
usable = account.get("is_email_valid") is not False and account.get("is_email_verified") is not False
email = (account.get("email") or "") if usable else ""
name = ((account.get("profile") or {}).get("nickname")
or (body.get("properties") or {}).get("nickname") or "")
return KakaoAccount(uid=uid, email=email, name=name) return KakaoAccount(uid=uid, email=email, name=name)
async def verify_code(code: str, redirect_uri: str = "", *,
client: httpx.AsyncClient | None = None) -> KakaoAccount:
"""프론트가 받은 인가 코드 하나로 신원까지. 호출측은 이 함수만 안다."""
async with _session(client) as http:
return await fetch_account(await exchange_code(code, redirect_uri, client=http), client=http)

View File

@ -0,0 +1,139 @@
"""카카오 로그인 — 인가 코드를 서버가 교환하고 회원번호를 꺼낸다.
★ 회원번호(`id`)가 챗봇 웹훅의 `appUserId` 와 같은 값이라는 것이 이 모듈의 존재 이유다.
그 값을 문자열로 유지하는지(정수로 흘리면 매핑 조회가 조용히 빗나간다)를 여기서 못 박는다.
"""
import httpx
import pytest
from config import config_models
from config.server_configs import kakao_login_config
from services.external import kakao_identity
from services.external.kakao_identity import KakaoNotConfigured, KakaoTokenInvalid
pytestmark = pytest.mark.asyncio
REST_KEY = "test-rest-key"
SECRET = "test-secret"
REDIRECT = "https://example.test/auth/kakao/callback"
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", REST_KEY)
monkeypatch.setenv("KAKAO_LOGIN_CLIENT_SECRET", SECRET)
monkeypatch.setenv("KAKAO_LOGIN_REDIRECT_URI", REDIRECT)
config_models.get_kakao_login_config.cache_clear()
fresh = config_models.get_kakao_login_config()
# 모듈이 import 시점에 잡아 둔 싱글턴을 본다 — 그 객체를 갈아 끼운다.
monkeypatch.setattr(kakao_identity, "kakao_login_config", fresh)
yield
config_models.get_kakao_login_config.cache_clear()
def _client(handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
async def test_인가코드를_시크릿과_함께_교환한다():
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["form"] = dict(httpx.QueryParams(request.content.decode()))
return httpx.Response(200, json={"access_token": "at-1"})
async with _client(handler) as client:
token = await kakao_identity.exchange_code("code-1", client=client)
assert token == "at-1"
assert seen["url"] == "https://kauth.kakao.com/oauth/token"
assert seen["form"] == {
"grant_type": "authorization_code",
"client_id": REST_KEY,
"client_secret": SECRET,
"redirect_uri": REDIRECT,
"code": "code-1",
}
async def test_호출부가_준_리다이렉트가_설정값을_이긴다():
"""로컬처럼 주소가 다른 환경 때문에 열어 둔 구멍이다 — 실제로 그 값이 나가는지 본다."""
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["form"] = dict(httpx.QueryParams(request.content.decode()))
return httpx.Response(200, json={"access_token": "at-2"})
async with _client(handler) as client:
await kakao_identity.exchange_code("code-2", "http://localhost/cb", client=client)
assert seen["form"]["redirect_uri"] == "http://localhost/cb"
async def test_교환_거절은_KakaoTokenInvalid():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "invalid_grant", "error_code": "KOE320"})
async with _client(handler) as client:
with pytest.raises(KakaoTokenInvalid):
await kakao_identity.exchange_code("used-code", client=client)
async def test_설정이_비면_호출하지_않고_KakaoNotConfigured(monkeypatch):
"""키가 없으면 카카오 로그인만 꺼진다 — 다른 외부 어댑터와 같은 규칙이다."""
monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", "")
config_models.get_kakao_login_config.cache_clear()
monkeypatch.setattr(kakao_identity, "kakao_login_config", config_models.get_kakao_login_config())
def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover - 불리면 실패다
raise AssertionError("설정이 없는데 카카오를 호출했다")
async with _client(handler) as client:
with pytest.raises(KakaoNotConfigured):
await kakao_identity.exchange_code("code", client=client)
async def test_회원번호는_문자열이다():
"""★ 카카오는 `id` 를 **정수**로 준다. 챗봇의 appUserId 는 문자열이라, 여기서 문자열로
맞춰 두지 않으면 `link_by_app_user_id` 조회가 조용히 빗나간다."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"id": 1234567890,
"kakao_account": {"email": "owner@example.test", "profile": {"nickname": "사장님"}},
})
async with _client(handler) as client:
account = await kakao_identity.fetch_account("at-1", client=client)
assert account.uid == "1234567890"
assert account.email == "owner@example.test"
assert account.name == "사장님"
async def test_이메일_이름이_없어도_신원은_성립한다():
"""동의 항목이라 비어 올 수 있다. 판정 키는 회원번호뿐이다."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": 7, "kakao_account": {}})
async with _client(handler) as client:
account = await kakao_identity.fetch_account("at-1", client=client)
assert (account.uid, account.email, account.name) == ("7", "", "")
@pytest.mark.parametrize("flag", ["is_email_valid", "is_email_verified"])
async def test_미인증_이메일은_신원으로_쓰지_않는다(flag):
"""★ auth_service 가 이 값으로 다른 수단 가입과의 충돌을 판정한다 — 확인 안 된 주소가
들어가면 남의 계정을 막거나 가로채는 데 쓰일 수 있다."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"id": 8,
"kakao_account": {"email": "someone@example.test", flag: False},
})
async with _client(handler) as client:
account = await kakao_identity.fetch_account("at-1", client=client)
assert account.email == ""
assert account.uid == "8"

View File

@ -0,0 +1,710 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type { HTTPValidationError, ReqChat } from ".././model";
import { customFetch } from "../../mutator/custom-fetch";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* 연결 상태.
* @summary Link State
*/
export const linkState = (
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/v1/agent/kakao/link`, method: "GET", signal },
options,
);
};
export const getLinkStateQueryKey = () => {
return [`/v1/agent/kakao/link`] as const;
};
export const getLinkStateQueryOptions = <
TData = Awaited<ReturnType<typeof linkState>>,
TError = unknown,
>(options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof linkState>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getLinkStateQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof linkState>>> = ({
signal,
}) => linkState(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof linkState>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type LinkStateQueryResult = NonNullable<
Awaited<ReturnType<typeof linkState>>
>;
export type LinkStateQueryError = unknown;
export function useLinkState<
TData = Awaited<ReturnType<typeof linkState>>,
TError = unknown,
>(
options: {
query: Partial<
UseQueryOptions<Awaited<ReturnType<typeof linkState>>, TError, TData>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof linkState>>,
TError,
Awaited<ReturnType<typeof linkState>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useLinkState<
TData = Awaited<ReturnType<typeof linkState>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof linkState>>, TError, TData>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof linkState>>,
TError,
Awaited<ReturnType<typeof linkState>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useLinkState<
TData = Awaited<ReturnType<typeof linkState>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof linkState>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Link State
*/
export function useLinkState<
TData = Awaited<ReturnType<typeof linkState>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof linkState>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getLinkStateQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* 일회용 코드를 낸다.
* @summary Issue Code
*/
export const issueCode = (
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/v1/agent/kakao/link/code`, method: "POST", signal },
options,
);
};
export const getIssueCodeMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof issueCode>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof issueCode>>,
TError,
void,
TContext
> => {
const mutationKey = ["issueCode"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof issueCode>>,
void
> = () => {
return issueCode(requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type IssueCodeMutationResult = NonNullable<
Awaited<ReturnType<typeof issueCode>>
>;
export type IssueCodeMutationError = unknown;
/**
* @summary Issue Code
*/
export const useIssueCode = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof issueCode>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof issueCode>>,
TError,
void,
TContext
> => {
const mutationOptions = getIssueCodeMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* @summary Disconnect
*/
export const disconnect = (
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/v1/agent/kakao/link/disconnect`, method: "POST", signal },
options,
);
};
export const getDisconnectMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof disconnect>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof disconnect>>,
TError,
void,
TContext
> => {
const mutationKey = ["disconnect"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof disconnect>>,
void
> = () => {
return disconnect(requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DisconnectMutationResult = NonNullable<
Awaited<ReturnType<typeof disconnect>>
>;
export type DisconnectMutationError = unknown;
/**
* @summary Disconnect
*/
export const useDisconnect = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof disconnect>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof disconnect>>,
TError,
void,
TContext
> => {
const mutationOptions = getDisconnectMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* 대화창을 열 수 있는지.
* @summary Status
*/
export const status = (
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/v1/agent/status`, method: "GET", signal },
options,
);
};
export const getStatusQueryKey = () => {
return [`/v1/agent/status`] as const;
};
export const getStatusQueryOptions = <
TData = Awaited<ReturnType<typeof status>>,
TError = unknown,
>(options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof status>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getStatusQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof status>>> = ({
signal,
}) => status(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof status>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type StatusQueryResult = NonNullable<Awaited<ReturnType<typeof status>>>;
export type StatusQueryError = unknown;
export function useStatus<
TData = Awaited<ReturnType<typeof status>>,
TError = unknown,
>(
options: {
query: Partial<
UseQueryOptions<Awaited<ReturnType<typeof status>>, TError, TData>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof status>>,
TError,
Awaited<ReturnType<typeof status>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useStatus<
TData = Awaited<ReturnType<typeof status>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof status>>, TError, TData>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof status>>,
TError,
Awaited<ReturnType<typeof status>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useStatus<
TData = Awaited<ReturnType<typeof status>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof status>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Status
*/
export function useStatus<
TData = Awaited<ReturnType<typeof status>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof status>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getStatusQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Chat
*/
export const chat = (
placeId: string,
reqChat: ReqChat,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{
url: `/v1/agent/chat/${placeId}`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: reqChat,
signal,
},
options,
);
};
export const getChatMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof chat>>,
TError,
{ placeId: string; data: ReqChat },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof chat>>,
TError,
{ placeId: string; data: ReqChat },
TContext
> => {
const mutationKey = ["chat"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof chat>>,
{ placeId: string; data: ReqChat }
> = (props) => {
const { placeId, data } = props ?? {};
return chat(placeId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ChatMutationResult = NonNullable<Awaited<ReturnType<typeof chat>>>;
export type ChatMutationBody = ReqChat;
export type ChatMutationError = HTTPValidationError;
/**
* @summary Chat
*/
export const useChat = <TError = HTTPValidationError, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof chat>>,
TError,
{ placeId: string; data: ReqChat },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof chat>>,
TError,
{ placeId: string; data: ReqChat },
TContext
> => {
const mutationOptions = getChatMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* 헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다.
* @summary Webhook
*/
export const webhook = (
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/v1/agent/kakao/webhook`, method: "POST", signal },
options,
);
};
export const getWebhookMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof webhook>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof webhook>>,
TError,
void,
TContext
> => {
const mutationKey = ["webhook"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof webhook>>,
void
> = () => {
return webhook(requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type WebhookMutationResult = NonNullable<
Awaited<ReturnType<typeof webhook>>
>;
export type WebhookMutationError = HTTPValidationError;
/**
* @summary Webhook
*/
export const useWebhook = <TError = HTTPValidationError, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof webhook>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof webhook>>,
TError,
void,
TContext
> => {
const mutationOptions = getWebhookMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* 헤더를 못 넣는 경우의 대안.
★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다.
* @summary Webhook With Path Secret
*/
export const webhookWithPathSecret = (
secret: string,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/v1/agent/kakao/webhook/${secret}`, method: "POST", signal },
options,
);
};
export const getWebhookWithPathSecretMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof webhookWithPathSecret>>,
TError,
{ secret: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof webhookWithPathSecret>>,
TError,
{ secret: string },
TContext
> => {
const mutationKey = ["webhookWithPathSecret"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof webhookWithPathSecret>>,
{ secret: string }
> = (props) => {
const { secret } = props ?? {};
return webhookWithPathSecret(secret, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type WebhookWithPathSecretMutationResult = NonNullable<
Awaited<ReturnType<typeof webhookWithPathSecret>>
>;
export type WebhookWithPathSecretMutationError = HTTPValidationError;
/**
* @summary Webhook With Path Secret
*/
export const useWebhookWithPathSecret = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof webhookWithPathSecret>>,
TError,
{ secret: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof webhookWithPathSecret>>,
TError,
{ secret: string },
TContext
> => {
const mutationOptions = getWebhookWithPathSecretMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};

View File

@ -23,6 +23,7 @@ import type {
import type { import type {
HTTPValidationError, HTTPValidationError,
ReqGoogleLogin, ReqGoogleLogin,
ReqKakaoLogin,
ReqLogin, ReqLogin,
ReqSignup, ReqSignup,
ReqUpdateMe, ReqUpdateMe,
@ -311,6 +312,98 @@ export const useGoogleLogin = <
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/**
* 카카오 **인가 코드**를 서버가 토큰으로 교환해 신원을 확인하고 JWT 를 발급한다. 처음 온 계정은 그 자리에서 만든다. ★ 코드는 1회용이라 같은 값으로 두 번 부르면 실패한다. ★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, 그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md).
* @summary 카카오 로그인
*/
export const kakaoLogin = (
reqKakaoLogin: ReqKakaoLogin,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<ResLogin>(
{
url: `/v1/auth/kakao`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: reqKakaoLogin,
signal,
},
options,
);
};
export const getKakaoLoginMutationOptions = <
TError = void | HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof kakaoLogin>>,
TError,
{ data: ReqKakaoLogin },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof kakaoLogin>>,
TError,
{ data: ReqKakaoLogin },
TContext
> => {
const mutationKey = ["kakaoLogin"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof kakaoLogin>>,
{ data: ReqKakaoLogin }
> = (props) => {
const { data } = props ?? {};
return kakaoLogin(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type KakaoLoginMutationResult = NonNullable<
Awaited<ReturnType<typeof kakaoLogin>>
>;
export type KakaoLoginMutationBody = ReqKakaoLogin;
export type KakaoLoginMutationError = void | HTTPValidationError;
/**
* @summary 카카오 로그인
*/
export const useKakaoLogin = <
TError = void | HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof kakaoLogin>>,
TError,
{ data: ReqKakaoLogin },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof kakaoLogin>>,
TError,
{ data: ReqKakaoLogin },
TContext
> => {
const mutationOptions = getKakaoLoginMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/** /**
* refresh 토큰으로 access 토큰을 재발급한다. * refresh 토큰으로 access 토큰을 재발급한다.
* @summary 액세스 토큰 갱신 * @summary 액세스 토큰 갱신

View File

@ -176,14 +176,7 @@ export function useHealthzHealthzGet<
} }
/** /**
* ★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), * healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다.
* @summary Readyz * @summary Readyz
*/ */
export const readyzReadyzGet = ( export const readyzReadyzGet = (

View File

@ -24,8 +24,7 @@ import { customFetch } from "../../mutator/custom-fetch";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/** /**
* 인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는 * 인증을 요구하지 않는다.
것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다).
* @summary 사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다 * @summary 사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다
*/ */
export const relay = ( export const relay = (

View File

@ -208,7 +208,7 @@ export function useListPlaceContents<
} }
/** /**
* 빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다. * 빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다.
* @summary 업장 주변정보 재수집 (TourAPI 반경) * @summary 업장 주변정보 재수집 (TourAPI 반경)
*/ */
export const syncPlace = ( export const syncPlace = (
@ -295,7 +295,7 @@ export const useSyncPlace = <TError = HTTPValidationError, TContext = unknown>(
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/** /**
* 숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다. * 숨긴 항목은 재수집이 되살리지 않는다.
* @summary 주변정보 숨김/해제 * @summary 주변정보 숨김/해제
*/ */
export const hidePlaceContent = ( export const hidePlaceContent = (

View File

@ -4,15 +4,18 @@
* Web4Ai API * Web4Ai API
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { import type {
DataTag, DataTag,
DefinedInitialDataOptions, DefinedInitialDataOptions,
DefinedUseQueryResult, DefinedUseQueryResult,
MutationFunction,
QueryClient, QueryClient,
QueryFunction, QueryFunction,
QueryKey, QueryKey,
UndefinedInitialDataOptions, UndefinedInitialDataOptions,
UseMutationOptions,
UseMutationResult,
UseQueryOptions, UseQueryOptions,
UseQueryResult, UseQueryResult,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
@ -185,3 +188,186 @@ export function useListMedia<
return query; return query;
} }
/**
* 그 사진을 REJECTED 로 내려 발행본에서 뺀다. ★ 지우지 않는다 — origin_url·source_type 이 남아 있어야 재게시 권리(docs/DECISIONS.md 1-2) 결론이 났을 때 되짚을 수 있고, 잘못 내렸을 때 되돌릴 수도 있다. 응답은 갱신된 목록이다.
* @summary 사진 내리기
*/
export const hideMedia = (
placeId: string,
mediaId: string,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<ResMediaList>(
{
url: `/v1/place/${placeId}/media/${mediaId}/hide`,
method: "POST",
signal,
},
options,
);
};
export const getHideMediaMutationOptions = <
TError = void | HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof hideMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof hideMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
> => {
const mutationKey = ["hideMedia"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof hideMedia>>,
{ placeId: string; mediaId: string }
> = (props) => {
const { placeId, mediaId } = props ?? {};
return hideMedia(placeId, mediaId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type HideMediaMutationResult = NonNullable<
Awaited<ReturnType<typeof hideMedia>>
>;
export type HideMediaMutationError = void | HTTPValidationError;
/**
* @summary 사진 내리기
*/
export const useHideMedia = <
TError = void | HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof hideMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof hideMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
> => {
const mutationOptions = getHideMediaMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* 목록 맨 앞으로 올린다. ★ 대표 사진은 별도 칸이 아니라 **목록의 첫 장**이다(site_payload.primary_media) — 칸을 따로 두면 검색 결과에 뜨는 그림과 화면 첫 장이 갈린다. 객실·메뉴 전용 사진(unit_id 가 있는 것)은 대표가 될 수 없다.
* @summary 대표 사진 지정
*/
export const setPrimaryMedia = (
placeId: string,
mediaId: string,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<ResMediaList>(
{
url: `/v1/place/${placeId}/media/${mediaId}/primary`,
method: "POST",
signal,
},
options,
);
};
export const getSetPrimaryMediaMutationOptions = <
TError = void | HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setPrimaryMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof setPrimaryMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
> => {
const mutationKey = ["setPrimaryMedia"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof setPrimaryMedia>>,
{ placeId: string; mediaId: string }
> = (props) => {
const { placeId, mediaId } = props ?? {};
return setPrimaryMedia(placeId, mediaId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SetPrimaryMediaMutationResult = NonNullable<
Awaited<ReturnType<typeof setPrimaryMedia>>
>;
export type SetPrimaryMediaMutationError = void | HTTPValidationError;
/**
* @summary 대표 사진 지정
*/
export const useSetPrimaryMedia = <
TError = void | HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof setPrimaryMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof setPrimaryMedia>>,
TError,
{ placeId: string; mediaId: string },
TContext
> => {
const mutationOptions = getSetPrimaryMediaMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};

View File

@ -6,11 +6,7 @@
*/ */
/** /**
* users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가. * users.provider 코드값.
한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.
이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면
남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절.
*/ */
export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider]; export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider];
@ -18,4 +14,5 @@ export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider];
export const AuthProvider = { export const AuthProvider = {
LOCAL: 1, LOCAL: 1,
GOOGLE: 2, GOOGLE: 2,
KAKAO: 3,
} as const; } as const;

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다. * site_versions.build_status 코드값.
*/ */
export type BuildStatus = (typeof BuildStatus)[keyof typeof BuildStatus]; export type BuildStatus = (typeof BuildStatus)[keyof typeof BuildStatus];

View File

@ -0,0 +1,19 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ConfirmArgs } from "./confirmArgs";
/**
* 직전 답의 확인 버튼이 그대로 돌려보내는 값.
*/
export interface Confirm {
/**
* @minLength 1
* @maxLength 40
*/
tool: string;
args?: ConfirmArgs;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ConfirmArgs = { [key: string]: unknown };

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type EditRedirectParams = {
/**
* @minLength 8
* @maxLength 200
*/
t: string;
};

View File

@ -9,7 +9,7 @@ import type { ErrorInfoCode } from "./errorInfoCode";
import type { ErrorInfoDesc } from "./errorInfoDesc"; import type { ErrorInfoDesc } from "./errorInfoDesc";
/** /**
* 모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다. * 모든 응답에 공통으로 실리는 결과 정보.
*/ */
export interface ErrorInfo { export interface ErrorInfo {
success?: ErrorInfoSuccess; success?: ErrorInfoSuccess;

View File

@ -6,10 +6,7 @@
*/ */
/** /**
* places.external_source 코드값. 동일 업소 검증에 쓴 외부 장소 DB. * places.external_source 코드값.
카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다.
네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다.
*/ */
export type ExternalPlaceSource = export type ExternalPlaceSource =
(typeof ExternalPlaceSource)[keyof typeof ExternalPlaceSource]; (typeof ExternalPlaceSource)[keyof typeof ExternalPlaceSource];

View File

@ -7,7 +7,6 @@
/** /**
* facts.status / faqs.status / routes.status 공용 검증 상태. * facts.status / faqs.status / routes.status 공용 검증 상태.
★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES).
*/ */
export type FactStatus = (typeof FactStatus)[keyof typeof FactStatus]; export type FactStatus = (typeof FactStatus)[keyof typeof FactStatus];

View File

@ -6,8 +6,7 @@
*/ */
/** /**
* fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 — * fact 기록 결과.
특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다.
*/ */
export type FactWriteOutcome = export type FactWriteOutcome =
(typeof FactWriteOutcome)[keyof typeof FactWriteOutcome]; (typeof FactWriteOutcome)[keyof typeof FactWriteOutcome];

View File

@ -12,7 +12,10 @@ export * from "./auditCheckDataRecommendation";
export * from "./authProvider"; export * from "./authProvider";
export * from "./buildStatus"; export * from "./buildStatus";
export * from "./checkSlugParams"; export * from "./checkSlugParams";
export * from "./confirm";
export * from "./confirmArgs";
export * from "./decision"; export * from "./decision";
export * from "./editRedirectParams";
export * from "./errorInfo"; export * from "./errorInfo";
export * from "./errorInfoCode"; export * from "./errorInfoCode";
export * from "./errorInfoDesc"; export * from "./errorInfoDesc";
@ -184,6 +187,8 @@ export * from "./reqBookingRequestEmail";
export * from "./reqBookingRequestGuests"; export * from "./reqBookingRequestGuests";
export * from "./reqBookingRequestMessage"; export * from "./reqBookingRequestMessage";
export * from "./reqBookingRequestStay"; export * from "./reqBookingRequestStay";
export * from "./reqChat";
export * from "./reqChatConfirm";
export * from "./reqCreateFaq"; export * from "./reqCreateFaq";
export * from "./reqCreateFaqSortOrder"; export * from "./reqCreateFaqSortOrder";
export * from "./reqCreateLink"; export * from "./reqCreateLink";
@ -194,6 +199,7 @@ export * from "./reqEditPost";
export * from "./reqExtractFacts"; export * from "./reqExtractFacts";
export * from "./reqGoogleLogin"; export * from "./reqGoogleLogin";
export * from "./reqHidePlaceContent"; export * from "./reqHidePlaceContent";
export * from "./reqKakaoLogin";
export * from "./reqLogin"; export * from "./reqLogin";
export * from "./reqPublishLocalContent"; export * from "./reqPublishLocalContent";
export * from "./reqReview"; export * from "./reqReview";

View File

@ -6,10 +6,7 @@
*/ */
/** /**
* jobs.status 코드값. 작업 큐 상태. * jobs.status 코드값.
전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면
PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter).
*/ */
export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus]; export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus];

View File

@ -6,10 +6,7 @@
*/ */
/** /**
* jobs.job_type 코드값. 수집·비전·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다. * jobs.job_type 코드값.
무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 —
크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다.
*/ */
export type JobType = (typeof JobType)[keyof typeof JobType]; export type JobType = (typeof JobType)[keyof typeof JobType];

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류. * place_channels.channel 코드값.
*/ */
export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel]; export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel];

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류. * local_contents.content_type 코드값.
*/ */
export type LocalContentType = export type LocalContentType =
(typeof LocalContentType)[keyof typeof LocalContentType]; (typeof LocalContentType)[keyof typeof LocalContentType];

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* local_contents.source 코드값. 어느 외부 API 에서 왔는지. * local_contents.source 코드값.
*/ */
export type LocalSource = (typeof LocalSource)[keyof typeof LocalSource]; export type LocalSource = (typeof LocalSource)[keyof typeof LocalSource];

View File

@ -18,11 +18,6 @@ import type { MediaDataCreatedAt } from "./mediaDataCreatedAt";
/** /**
* 사진 1건. * 사진 1건.
★ source_type 과 origin_url 을 반드시 함께 내려보낸다 — 크롤링 이미지의 재게시 권리가
아직 미결이라(docs/DECISIONS.md 1-2), 결론이 '불가'로 나면 발행에서 source_type = CRAWL 을
통째로 제외해야 한다. 화면이 출처를 모르면 무엇이 빠질지도 미리 보여줄 수 없다.
origin_url 은 그때 '이 사진은 어디서 왔는가'를 증명하는 유일한 근거다.
*/ */
export interface MediaData { export interface MediaData {
media_id: string; media_id: string;

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다. * media.status 코드값.
*/ */
export type MediaStatus = (typeof MediaStatus)[keyof typeof MediaStatus]; export type MediaStatus = (typeof MediaStatus)[keyof typeof MediaStatus];

View File

@ -17,9 +17,6 @@ import type { MySiteDataThumbnailUrl } from "./mySiteDataThumbnailUrl";
/** /**
* 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다.
*/ */
export interface MySiteData { export interface MySiteData {
place_id: string; place_id: string;

View File

@ -17,8 +17,6 @@ import type { OpsSiteDataOwnerName } from "./opsSiteDataOwnerName";
/** /**
* 전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자. * 전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자.
★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다.
*/ */
export interface OpsSiteData { export interface OpsSiteData {
place_id: string; place_id: string;

View File

@ -14,7 +14,7 @@ import type { OpsUserDataCreatedAt } from "./opsUserDataCreatedAt";
import type { OpsUserDataLastAccessedAt } from "./opsUserDataLastAccessedAt"; import type { OpsUserDataLastAccessedAt } from "./opsUserDataLastAccessedAt";
/** /**
* 전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다. * 전 계정 목록의 한 줄.
*/ */
export interface OpsUserData { export interface OpsUserData {
user_id: string; user_id: string;

View File

@ -15,7 +15,7 @@ import type { PlaceCandidatePlaceUrl } from "./placeCandidatePlaceUrl";
import type { PlaceCandidateNaverPlaceUrl } from "./placeCandidateNaverPlaceUrl"; import type { PlaceCandidateNaverPlaceUrl } from "./placeCandidateNaverPlaceUrl";
/** /**
* 외부 장소 DB 에서 찾은 후보 1건. UI 가 이걸 카드로 그려 사람이 고른다. * 외부 장소 DB 에서 찾은 후보 1건.
*/ */
export interface PlaceCandidate { export interface PlaceCandidate {
external_place_id?: PlaceCandidateExternalPlaceId; external_place_id?: PlaceCandidateExternalPlaceId;

View File

@ -6,8 +6,7 @@
*/ */
/** /**
* places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1. * places.category 코드값.
업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가.
*/ */
export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory]; export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory];

View File

@ -11,11 +11,6 @@ import type { PlaceSearchItemNaverPlaceUrl } from "./placeSearchItemNaverPlaceUr
/** /**
* 공개 검색 결과 1건. * 공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.
*/ */
export interface PlaceSearchItem { export interface PlaceSearchItem {
name?: string; name?: string;

View File

@ -6,8 +6,7 @@
*/ */
/** /**
* places.status 코드값. 사업장 생애주기. * places.status 코드값.
해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다).
*/ */
export type PlaceStatus = (typeof PlaceStatus)[keyof typeof PlaceStatus]; export type PlaceStatus = (typeof PlaceStatus)[keyof typeof PlaceStatus];

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3). * publish_logs.reject_reason 코드값.
*/ */
export type PublishRejectReason = export type PublishRejectReason =
(typeof PublishRejectReason)[keyof typeof PublishRejectReason]; (typeof PublishRejectReason)[keyof typeof PublishRejectReason];

View File

@ -9,10 +9,7 @@ import type { RenderStatusDataRenderedVersion } from "./renderStatusDataRendered
import type { RenderStatusDataError } from "./renderStatusDataError"; import type { RenderStatusDataError } from "./renderStatusDataError";
/** /**
* 정적 페이지가 실제로 구워졌는지. 프리렌더가 남긴 보고서를 그대로 옮긴다. * 정적 페이지가 실제로 구워졌는지.
★ 발행 기록(DB)과 실제 페이지(파일)는 다른 곳에 산다. 이게 없으면 프리렌더가 깨져도
DB 는 "발행됨"이라 말하고 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
*/ */
export interface RenderStatusData { export interface RenderStatusData {
state?: string; state?: string;

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ReqChatConfirm } from "./reqChatConfirm";
export interface ReqChat {
/** @maxLength 500 */
message?: string;
confirm?: ReqChatConfirm;
}

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { Confirm } from "./confirm";
export type ReqChatConfirm = Confirm | null;

View File

@ -8,9 +8,6 @@ import type { ReqCreateFaqSortOrder } from "./reqCreateFaqSortOrder";
/** /**
* 사장님이 직접 쓴 FAQ. * 사장님이 직접 쓴 FAQ.
★ generated_by 를 요청으로 받지 않는다 — 받으면 LLM 생성물을 사람이 쓴 것처럼 올려
승인 절차를 통째로 건너뛸 수 있다. 출처는 서버가 OWNER 로 고정한다.
*/ */
export interface ReqCreateFaq { export interface ReqCreateFaq {
question?: string; question?: string;

View File

@ -7,13 +7,6 @@
/** /**
* 사장님이 붙여넣은 원문에서 fact 를 뽑는다. * 사장님이 붙여넣은 원문에서 fact 를 뽑는다.
★ 왜 이 입구가 필요한가 (2026-08-31)
TourAPI 에 없고 네이버에도 요금표뿐인 업소가 흔하다(실측: 조이모텔 — 수집 fact 6건이
전부 대실·숙박 요금이었다). 그런 업소는 자동 수집만으로는 발행 근거가 영영 안 찬다.
폴백 3단계의 2번(사장님이 직접 붙여넣기)이 여기다.
★ 뽑은 값은 전부 **후보(UNVERIFIED)** 로 들어간다. 사장님이 확인해야 사이트에 나간다.
*/ */
export interface ReqExtractFacts { export interface ReqExtractFacts {
text?: string; text?: string;

View File

@ -6,10 +6,7 @@
*/ */
/** /**
* 구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다. * 구글 로그인.
필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 —
`access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다.
*/ */
export interface ReqGoogleLogin { export interface ReqGoogleLogin {
credential?: string; credential?: string;

View File

@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
/**
* 카카오 로그인.
★ 구글은 브라우저가 받은 ID 토큰(credential)을 우리가 검증하지만, 카카오는 **인가 코드**를
받아 서버가 토큰으로 바꾼다 — 클라이언트 시크릿을 브라우저에 둘 수 없기 때문이다.
그래서 필드 이름이 다르다.
★ `redirect_uri` 는 프론트가 인가를 요청할 때 쓴 값이다. 카카오가 교환 시점에 이 값을
대조하므로 **글자 하나까지 같아야** 한다. 비우면 서버 설정값을 쓴다 — 한 벌로 쓰는
평소에는 보낼 필요가 없고, 로컬처럼 주소가 다른 환경에서만 채운다.
*/
export interface ReqKakaoLogin {
code?: string;
redirect_uri?: string;
}

View File

@ -6,12 +6,7 @@
*/ */
/** /**
* 예전 버전으로 공개 주소를 되돌린다. * 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다.
★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면
(보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서
돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는
그대로 통과한다).
*/ */
export interface ReqRollback { export interface ReqRollback {
target_version: number; target_version: number;

View File

@ -7,11 +7,7 @@
import type { ReqSignupName } from "./reqSignupName"; import type { ReqSignupName } from "./reqSignupName";
/** /**
* id/pw 가입. 가입 = 계정 1개다. * id/pw 가입.
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
**중복 판정용** 이다.
*/ */
export interface ReqSignup { export interface ReqSignup {
id?: string; id?: string;

View File

@ -7,9 +7,6 @@
/** /**
* 사이트 주소(네임스페이스) 예약. * 사이트 주소(네임스페이스) 예약.
★ 서버가 상호명으로 자동 확정하지 않는다 — 사장님이 고른다.
주소는 AI 검색이 색인하는 영구 식별자라, 한 번 정해지면 되돌리는 비용이 사장님 몫이 된다.
*/ */
export interface ReqSiteSlug { export interface ReqSiteSlug {
slug?: string; slug?: string;

View File

@ -7,13 +7,7 @@
import type { PublishAction } from "./publishAction"; import type { PublishAction } from "./publishAction";
/** /**
* 발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 — * 발행 상태 전이.
색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다.
★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도
(`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가
'아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15).
무엇을 할지는 부르는 쪽이 적는다.
*/ */
export interface ReqSiteStatus { export interface ReqSiteStatus {
action: PublishAction; action: PublishAction;

View File

@ -7,10 +7,6 @@
/** /**
* 템플릿 선택 저장. * 템플릿 선택 저장.
★ 서버는 값을 검증하지 않는다. 템플릿 목록은 프론트(배리에이션 레지스트리)가 소유하므로
여기서 화이트리스트를 두면 템플릿을 하나 늘릴 때마다 백엔드를 같이 고쳐야 한다.
잘못된 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨린다 — 화면이 깨지지 않는다.
*/ */
export interface ReqSiteTemplate { export interface ReqSiteTemplate {
template_id?: string; template_id?: string;

View File

@ -7,32 +7,7 @@
import type { ReqSiteThemeTheme } from "./reqSiteThemeTheme"; import type { ReqSiteThemeTheme } from "./reqSiteThemeTheme";
/** /**
* 디자인(색·서체·섹션) 저장. 에디터 좌측 패널과 [디자인] 탭이 만든 결과 그대로 온다. * 색·섹션 저장.
★ Req_SiteTemplate 과 같은 철학이다 — 서버는 값을 해석하지도 검증하지도 않는다.
섹션 목록도, 배리에이션 키도, 색 토큰 이름도 프론트(배리에이션 레지스트리)가 소유한다.
여기에 화이트리스트를 두면 프론트에 섹션이나 배리에이션이 하나 늘 때마다 백엔드를 같이 고쳐야 하고,
그 사이 사장님이 고른 값은 조용히 버려진다. 모르는 값이 들어와도 발행 잡이 업종 기본으로
떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다.
★ colorPaletteId 는 **에디터 복원 전용**이다. 사장님이 고른 색 프리셋 id 이고,
발행 렌더러는 이걸 안 쓰고 해석된 colors 만 쓴다. DB 에는 저장하고 응답으로도 그대로 돌려주지만,
발행 payload 의 theme 에는 싣지 않는다 — 발행 계약(SitePayload.SiteTheme)에 없는 필드다.
★ templateId 는 이 body 에 없다. sites.template_id 컬럼과 POST /template 이 계속 담당한다 —
두 곳에 두면 어느 쪽이 진짜인지 갈린다.
★ 딱 하나 막는 것은 크기다. 해석하지 않는 값을 그대로 보관한다는 건 곧 무엇이든 들어올 수 있다는
뜻이라, 상한이 없으면 jsonb 컬럼 하나가 DB 와 스냅샷을 통째로 부풀린다.
상한(services/site_service._THEME_MAX_BYTES)은 서비스가 직렬화 크기로 잰다 —
필드 개수로 재면 값 하나가 긴 경우를 못 막는다.
*/ */
export interface ReqSiteTheme { export interface ReqSiteTheme {
theme?: ReqSiteThemeTheme; theme?: ReqSiteThemeTheme;

View File

@ -7,9 +7,6 @@
/** /**
* 정적 빌드 시작. * 정적 빌드 시작.
publish=true 면 발행 검수 게이트를 통과했을 때 바로 발행까지 한다.
★ 게이트를 통과하지 못하면 발행되지 않는다 — 우회 옵션은 없다.
*/ */
export interface ReqStartBuild { export interface ReqStartBuild {
publish?: boolean; publish?: boolean;

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* 수집 시작. 몇 분 걸리므로 동기로 처리하지 않고 잡을 적재한 뒤 즉시 응답한다. * 수집 시작.
*/ */
export interface ReqStartCollect { export interface ReqStartCollect {
link_ids?: string[]; link_ids?: string[];

View File

@ -7,8 +7,6 @@
/** /**
* 소개문·FAQ 생성 시작. * 소개문·FAQ 생성 시작.
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다).
*/ */
export interface ReqStartCopy { export interface ReqStartCopy {
resume?: boolean; resume?: boolean;

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* 사진 분석 시작. 사진 20~50장이라 몇 분 걸린다 — 잡으로 처리한다. * 사진 분석 시작.
*/ */
export interface ReqStartVision { export interface ReqStartVision {
force?: boolean; force?: boolean;

View File

@ -8,7 +8,7 @@ import type { FactStatus } from "./factStatus";
import type { ReqTransitionFactValue } from "./reqTransitionFactValue"; import type { ReqTransitionFactValue } from "./reqTransitionFactValue";
/** /**
* 검증 상태 전이. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다. * 검증 상태 전이.
*/ */
export interface ReqTransitionFact { export interface ReqTransitionFact {
status?: FactStatus; status?: FactStatus;

View File

@ -9,9 +9,7 @@ import type { ReqTransitionFaqQuestion } from "./reqTransitionFaqQuestion";
import type { ReqTransitionFaqAnswer } from "./reqTransitionFaqAnswer"; import type { ReqTransitionFaqAnswer } from "./reqTransitionFaqAnswer";
/** /**
* 검증 상태 전이. 허용 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)가 유일한 소스다. * 검증 상태 전이.
CORRECTED 로 갈 때는 고친 question / answer 중 하나 이상이 필요하다(다른 전이에선 무시).
*/ */
export interface ReqTransitionFaq { export interface ReqTransitionFaq {
status?: FactStatus; status?: FactStatus;

View File

@ -11,9 +11,7 @@ import type { ReqUpsertFactSourceUrl } from "./reqUpsertFactSourceUrl";
import type { ReqUpsertFactExpiresAt } from "./reqUpsertFactExpiresAt"; import type { ReqUpsertFactExpiresAt } from "./reqUpsertFactExpiresAt";
/** /**
* fact 기록. key 는 사업장 업종의 스키마에 있는 것만 허용한다. * fact 기록.
★ source_type 이 owner 가 아니면 source_url 이 필수다 — 출처 없는 사실은 받지 않는다.
*/ */
export interface ReqUpsertFact { export interface ReqUpsertFact {
key?: string; key?: string;

View File

@ -16,10 +16,6 @@ import type { ReqVerifyPlaceCategoryName } from "./reqVerifyPlaceCategoryName";
/** /**
* 카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정). * 카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정).
★ 이 단계를 통과해야 수집이 열린다.
external_place_id 는 소스에 따라 없을 수 있다 — 네이버는 고유 장소 id 를 주지 않는다.
그 경우 상호명 + 도로명주소가 중복 판정 키가 되므로 road_address 를 반드시 채워야 한다.
*/ */
export interface ReqVerifyPlace { export interface ReqVerifyPlace {
source?: ExternalPlaceSource; source?: ExternalPlaceSource;

View File

@ -7,15 +7,6 @@
/** /**
* 네이버 플레이스 URL 하나로 동일 업소를 확정한다. * 네이버 플레이스 URL 하나로 동일 업소를 확정한다.
★ 왜 이 경로가 필요한가
상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울').
Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다.
그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장
정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다.
서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로
옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다).
*/ */
export interface ReqVerifyPlaceByUrl { export interface ReqVerifyPlaceByUrl {
url?: string; url?: string;

View File

@ -10,9 +10,6 @@ import type { ResExtractedFact } from "./resExtractedFact";
/** /**
* 뽑힌 것과 버려진 것을 **둘 다** 돌려준다. * 뽑힌 것과 버려진 것을 **둘 다** 돌려준다.
★ 조용히 버리지 않는다 — 사장님이 "내가 쓴 체크인 시간이 왜 안 들어갔지" 를
화면에서 바로 확인할 수 있어야 한다.
*/ */
export interface ResExtractFacts { export interface ResExtractFacts {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -9,9 +9,7 @@ import type { ResGenerateOneMsg } from "./resGenerateOneMsg";
import type { ResGenerateOnePost } from "./resGenerateOnePost"; import type { ResGenerateOnePost } from "./resGenerateOnePost";
/** /**
* 개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때(2026-09-17, 사장님 * 개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때.
지시: "개별적으로 새로 만들수있게 해줘"). 실패하면 post 가 없다(그 날짜가 이미 찼거나
소재가 바닥났다).
*/ */
export interface ResGenerateOne { export interface ResGenerateOne {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -9,7 +9,7 @@ import type { ResJobMsg } from "./resJobMsg";
import type { ResJobJob } from "./resJobJob"; import type { ResJobJob } from "./resJobJob";
/** /**
* 잡 상태 폴링 응답. 수집·빌드는 몇 분 걸리므로 클라이언트가 이 엔드포인트를 폴링한다. * 잡 상태 폴링 응답.
*/ */
export interface ResJob { export interface ResJob {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -14,8 +14,7 @@ import type { ResLocalGuideItinerariesItem } from "./resLocalGuideItinerariesIte
import type { ResLocalGuideSyncedAt } from "./resLocalGuideSyncedAt"; import type { ResLocalGuideSyncedAt } from "./resLocalGuideSyncedAt";
/** /**
* 에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다 * 에디터 캔버스가 그리는 지역 가이드.
(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다.
*/ */
export interface ResLocalGuide { export interface ResLocalGuide {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -11,9 +11,6 @@ import type { PlaceSearchItem } from "./placeSearchItem";
/** /**
* 상호명 공개 검색 결과. * 상호명 공개 검색 결과.
★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 —
만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다.
*/ */
export interface ResPlaceSearch { export interface ResPlaceSearch {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -11,7 +11,7 @@ import type { ResSiteSlugReason } from "./resSiteSlugReason";
import type { ResSiteSlugSuggestion } from "./resSiteSlugSuggestion"; import type { ResSiteSlugSuggestion } from "./resSiteSlugSuggestion";
/** /**
* 주소 저장 결과. 거부됐으면 왜/대안을 check 와 같은 코드로 돌려준다. * 주소 저장 결과.
*/ */
export interface ResSiteSlug { export interface ResSiteSlug {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -10,7 +10,7 @@ import type { ResSlugCheckReason } from "./resSlugCheckReason";
import type { ResSlugCheckSuggestion } from "./resSlugCheckSuggestion"; import type { ResSlugCheckSuggestion } from "./resSlugCheckSuggestion";
/** /**
* 주소 사용 가능 확인. UI 가 타이핑 중에 호출한다. * 주소 사용 가능 확인.
*/ */
export interface ResSlugCheck { export interface ResSlugCheck {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -10,7 +10,7 @@ import type { ResStartCollectJobId } from "./resStartCollectJobId";
import type { ResStartCollectStatus } from "./resStartCollectStatus"; import type { ResStartCollectStatus } from "./resStartCollectStatus";
/** /**
* 수집 잡 적재 결과. 클라이언트는 job_id 로 GET /v1/job/{job_id} 를 폴링한다. * 수집 잡 적재 결과.
*/ */
export interface ResStartCollect { export interface ResStartCollect {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -8,7 +8,7 @@ import type { ErrorInfo } from "./errorInfo";
import type { ResSyncPlaceMsg } from "./resSyncPlaceMsg"; import type { ResSyncPlaceMsg } from "./resSyncPlaceMsg";
/** /**
* 업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지. * 업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤).
*/ */
export interface ResSyncPlace { export interface ResSyncPlace {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -12,9 +12,6 @@ import type { PlaceCandidate } from "./placeCandidate";
/** /**
* 동일 업소 후보 목록. * 동일 업소 후보 목록.
★ 자동 판정을 신뢰하지 않는다. outcome 이 MATCHED 여도 후보를 전부 내려보내
UI 가 사람에게 확인시킬 수 있게 한다 — 남의 가게가 섞이면 그게 제일 비싼 실수다.
*/ */
export interface ResVerifyCandidates { export interface ResVerifyCandidates {
result?: ErrorInfo; result?: ErrorInfo;

View File

@ -9,11 +9,7 @@ import type { ShowcaseItemRegion } from "./showcaseItemRegion";
import type { ShowcaseItemThumbnailUrl } from "./showcaseItemThumbnailUrl"; import type { ShowcaseItemThumbnailUrl } from "./showcaseItemThumbnailUrl";
/** /**
* 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** * 랜딩 쇼케이스 카드 한 장.
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.
*/ */
export interface ShowcaseItem { export interface ShowcaseItem {
name: string; name: string;

View File

@ -6,7 +6,7 @@
*/ */
/** /**
* sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다. * sites.status 코드값.
*/ */
export type SiteStatus = (typeof SiteStatus)[keyof typeof SiteStatus]; export type SiteStatus = (typeof SiteStatus)[keyof typeof SiteStatus];

View File

@ -7,7 +7,6 @@
/** /**
* facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. * facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값.
값이 어디서 왔는지 — 모든 사실은 출처를 갖는다.
*/ */
export type SourceType = (typeof SourceType)[keyof typeof SourceType]; export type SourceType = (typeof SourceType)[keyof typeof SourceType];

View File

@ -7,8 +7,6 @@
/** /**
* users.role 코드값. * users.role 코드값.
1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다.
*/ */
export type UserRole = (typeof UserRole)[keyof typeof UserRole]; export type UserRole = (typeof UserRole)[keyof typeof UserRole];

View File

@ -23,6 +23,7 @@ import type {
import type { import type {
ApprovePageParams, ApprovePageParams,
CheckSlugParams, CheckSlugParams,
EditRedirectParams,
GenerateMyPostForDateParams, GenerateMyPostForDateParams,
GenerateMyPostsParams, GenerateMyPostsParams,
HTTPValidationError, HTTPValidationError,
@ -613,7 +614,7 @@ export const useSetSlug = <
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/** /**
* 위저드에서 고른 템플릿을 sites.template_id 에 저장한다(사이트 행이 없으면 만든다). ★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. 길이(100자)만 막는다. ★ 주소와 달리 발행 뒤에도 바꿀 수 있다: 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않는다. 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). * sites.template_id 에 저장한다(사이트 행이 없으면 만든다). 업종 허용 목록에 없는 id는 거절한다. 이미 발행된 사이트면 재빌드 표시(content_updated_at)를 찍는다.
* @summary 템플릿(디자인) 선택 저장 * @summary 템플릿(디자인) 선택 저장
*/ */
export const setTemplate = ( export const setTemplate = (
@ -706,8 +707,8 @@ export const useSetTemplate = <
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/** /**
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId","body","data"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). * sites.theme 에 저장한다(사이트 행이 없으면 만든다). body: {"theme":{"colors","look","colorPaletteId","sections":[{"id","name","enabled","locked","body","data"}]}}. 배열 순서가 곧 섹션 순서다. 크기(64KB)만 막는다. 빈 값({})이면 업종 기본으로 되돌린다.
* @summary 디자인(색·서체·섹션) 저장 * @summary 디자인(색·섹션) 저장
*/ */
export const setTheme = ( export const setTheme = (
placeId: string, placeId: string,
@ -772,7 +773,7 @@ export type SetThemeMutationBody = ReqSiteTheme;
export type SetThemeMutationError = void | HTTPValidationError; export type SetThemeMutationError = void | HTTPValidationError;
/** /**
* @summary 디자인(색·서체·섹션) 저장 * @summary 디자인(색·섹션) 저장
*/ */
export const useSetTheme = < export const useSetTheme = <
TError = void | HTTPValidationError, TError = void | HTTPValidationError,
@ -1752,6 +1753,153 @@ export const useSendBookingRequest = <
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/**
* 메일의 '고쳐서 올리려면'.
★ 액세스 토큰은 **쿼리가 아니라 프래그먼트**로 넘긴다 — 프래그먼트는 서버 로그와 Referer 에
남지 않는다. 예전처럼 쿼리에 실으면 주소가 500자가 되는 것보다, 메일 전달 한 번이
자정까지의 권한 양도가 되는 쪽이 더 나빴다(services/post_service.open_editor).
* @summary 수정하기 — 일회용 코드를 세션으로 바꿔 편집 화면으로 보낸다
*/
export const editRedirect = (
params: EditRedirectParams,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/v1/site/post/edit`, method: "GET", params, signal },
options,
);
};
export const getEditRedirectQueryKey = (params?: EditRedirectParams) => {
return [`/v1/site/post/edit`, ...(params ? [params] : [])] as const;
};
export const getEditRedirectQueryOptions = <
TData = Awaited<ReturnType<typeof editRedirect>>,
TError = HTTPValidationError,
>(
params: EditRedirectParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof editRedirect>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getEditRedirectQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof editRedirect>>> = ({
signal,
}) => editRedirect(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof editRedirect>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type EditRedirectQueryResult = NonNullable<
Awaited<ReturnType<typeof editRedirect>>
>;
export type EditRedirectQueryError = HTTPValidationError;
export function useEditRedirect<
TData = Awaited<ReturnType<typeof editRedirect>>,
TError = HTTPValidationError,
>(
params: EditRedirectParams,
options: {
query: Partial<
UseQueryOptions<Awaited<ReturnType<typeof editRedirect>>, TError, TData>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof editRedirect>>,
TError,
Awaited<ReturnType<typeof editRedirect>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useEditRedirect<
TData = Awaited<ReturnType<typeof editRedirect>>,
TError = HTTPValidationError,
>(
params: EditRedirectParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof editRedirect>>, TError, TData>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof editRedirect>>,
TError,
Awaited<ReturnType<typeof editRedirect>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useEditRedirect<
TData = Awaited<ReturnType<typeof editRedirect>>,
TError = HTTPValidationError,
>(
params: EditRedirectParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof editRedirect>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary 수정하기 — 일회용 코드를 세션으로 바꿔 편집 화면으로 보낸다
*/
export function useEditRedirect<
TData = Awaited<ReturnType<typeof editRedirect>>,
TError = HTTPValidationError,
>(
params: EditRedirectParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof editRedirect>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getEditRedirectQueryOptions(params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/** /**
* @summary 승인 확정 — 누르는 즉시 게재 큐에 넣는다 * @summary 승인 확정 — 누르는 즉시 게재 큐에 넣는다
*/ */
@ -3183,8 +3331,7 @@ export const useSubmitReview = <
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/** /**
* 날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고, * 날씨(/v1/local/weather)와 같은 공개 조회다.
화면은 붙은 뒤 이 주소로 최신을 받아 덮는다.
* @summary 게재된 후기 — 발행본이 붙은 뒤 받아 간다 * @summary 게재된 후기 — 발행본이 붙은 뒤 받아 간다
*/ */
export const listReviews = ( export const listReviews = (

View File

@ -0,0 +1,31 @@
import {Button} from '@/components/ui/button';
import {isKakaoLoginEnabled, startKakaoLogin} from '@/lib/kakaoIdentity';
/** 카카오 인가 화면으로 보낸다.
*
* ★ 구글처럼 SDK 가 그려 주는 버튼이 아니라 우리가 그린다 — 카카오 로그인은 페이지 이동이라
* 붙일 스크립트가 없다. 색·문구는 카카오 디자인 가이드의 값(#FEE500 / 검정 85%)이다. */
export function KakaoSignInButton({
label = '카카오로 시작하기',
returnTo,
}: {
label?: string;
returnTo?: string;
}) {
if (!isKakaoLoginEnabled()) return null;
return (
<Button
type="button"
variant="ghost"
className="w-full bg-[#FEE500] text-[rgba(0,0,0,0.85)] hover:bg-[#F2DA00]"
onClick={() => startKakaoLogin(returnTo)}
>
{/* 카카오 말풍선. 로고 이미지를 외부에서 받아 오면 로그인 버튼이 남의 CDN 에 묶인다. */}
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-4 fill-current">
<path d="M12 3C6.9 3 2.8 6.2 2.8 10.2c0 2.5 1.7 4.8 4.3 6.1l-1 3.7c-.1.3.3.6.6.4l4.4-2.9c.3 0 .6.1.9.1 5.1 0 9.2-3.2 9.2-7.4S17.1 3 12 3z" />
</svg>
<span>{label}</span>
</Button>
);
}

View File

@ -3,10 +3,12 @@ import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react'; import {LogIn} from 'lucide-react';
import {googleLogin, login} from '@/api'; import {googleLogin, login} from '@/api';
import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton'; import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton';
import {KakaoSignInButton} from '@/components/auth/KakaoSignInButton';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify'; import {notifyApiError} from '@/lib/notify';
import {isGoogleLoginEnabled} from '@/lib/googleIdentity'; import {isGoogleLoginEnabled} from '@/lib/googleIdentity';
import {isKakaoLoginEnabled} from '@/lib/kakaoIdentity';
import {establishSession} from '@/lib/session'; import {establishSession} from '@/lib/session';
interface SignInFormProps { interface SignInFormProps {
@ -107,14 +109,17 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
</Button> </Button>
{/* 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다. */} {/* 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다. */}
{isGoogleLoginEnabled() && ( {(isKakaoLoginEnabled() || isGoogleLoginEnabled()) && (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">또는</span> <span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
</div> </div>
<GoogleSignInButton onCredential={handleGoogle} text="signin_with" /> {/* ★ 카카오를 위에 둔다 — 카톡 채널로 홈페이지를 관리하려면 이 수단이어야
회원번호가 이어진다(docs/AGENT.md). 구글로 들어오면 6자리 코드를 거쳐야 한다. */}
<KakaoSignInButton />
{isGoogleLoginEnabled() && <GoogleSignInButton onCredential={handleGoogle} text="signin_with" />}
</> </>
)} )}

View File

@ -0,0 +1,53 @@
/** 카카오 로그인 어댑터 — 인가 화면으로 보내고, 돌아온 코드를 백엔드에 넘긴다.
*
* ★ 구글(GIS)과 모양이 다르다. 구글은 스크립트가 브라우저 안에서 토큰까지 만들어 주지만,
* 카카오는 **클라이언트 시크릿**이 있어 토큰 교환을 서버가 해야 한다. 그래서 여기서는
* SDK 를 붙이지 않고 인가 URL 로 이동만 시킨다 — 프론트가 아는 것은 공개값 둘뿐이다.
*/
const AUTHORIZE_URL = 'https://kauth.kakao.com/oauth/authorize';
/** 인가 URL 의 client_id 로 쓰인다. 비밀이 아니다 — 주소창에 그대로 실려 나간다. */
export const KAKAO_REST_API_KEY = import.meta.env.VITE_KAKAO_REST_API_KEY ?? '';
/** ★ 카카오 콘솔에 등록한 값과 **글자 하나까지** 같아야 한다. 인가 때와 교환 때 둘 다 대조된다. */
export const KAKAO_REDIRECT_URI = import.meta.env.VITE_KAKAO_REDIRECT_URI ?? '';
/** 빈 값이면 버튼을 올리지 않는다 — 누르면 실패하는 버튼을 두지 않는다(구글과 같은 규칙). */
export function isKakaoLoginEnabled(): boolean {
return Boolean(KAKAO_REST_API_KEY && KAKAO_REDIRECT_URI);
}
// ★ state 는 sessionStorage 에 둔다. localStorage 면 다른 탭에서 시작한 로그인의 state 를
// 이 탭이 승인해 버린다 — CSRF 를 막으려고 만든 값이 탭 사이에 공유되면 의미가 없다.
const STATE_KEY = 'kakao_oauth_state';
const RETURN_KEY = 'kakao_oauth_return';
function newState(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
/** 인가 화면으로 보낸다. 돌아올 곳(`returnTo`)은 state 와 함께 보관한다. */
export function startKakaoLogin(returnTo = '/sites'): void {
const state = newState();
sessionStorage.setItem(STATE_KEY, state);
sessionStorage.setItem(RETURN_KEY, returnTo);
const url = new URL(AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', KAKAO_REST_API_KEY);
url.searchParams.set('redirect_uri', KAKAO_REDIRECT_URI);
url.searchParams.set('state', state);
window.location.assign(url.toString());
}
/** 콜백에서 state 를 대조한다. **한 번 쓰면 지운다** — 뒤로 가기로 같은 코드를 다시 보내지 못하게. */
export function consumeKakaoState(state: string | null): {ok: boolean; returnTo: string} {
const saved = sessionStorage.getItem(STATE_KEY);
const returnTo = sessionStorage.getItem(RETURN_KEY) || '/sites';
sessionStorage.removeItem(STATE_KEY);
sessionStorage.removeItem(RETURN_KEY);
return {ok: Boolean(saved) && saved === state, returnTo};
}

View File

@ -0,0 +1,70 @@
/** 카카오 인가 화면에서 돌아오는 자리(`/auth/kakao/callback`).
*
* ★ 화면이 하는 일은 코드를 백엔드에 넘기는 것뿐이다. 토큰 교환은 서버가 한다 —
* 클라이언트 시크릿이 필요해서다(lib/kakaoIdentity 주석).
* ★ 코드는 **1회용**이다. 그래서 StrictMode 의 이중 실행·재렌더로 두 번 보내지 않도록
* ref 로 한 번만 보낸다. 두 번째 호출은 카카오가 거절하고 화면에는 로그인 실패로만 보인다.
*/
import {useEffect, useRef, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router';
import {kakaoLogin} from '@/api';
import {KAKAO_REDIRECT_URI, consumeKakaoState} from '@/lib/kakaoIdentity';
import {establishSession} from '@/lib/session';
export default function KakaoCallbackPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const [error, setError] = useState('');
const sent = useRef(false);
useEffect(() => {
if (sent.current) return;
sent.current = true;
const code = params.get('code');
// 사장님이 인가 화면에서 [취소] 를 누르면 code 대신 error 가 온다 — 실패가 아니라 취소다.
if (!code) {
navigate('/login', {replace: true});
return;
}
const {ok, returnTo} = consumeKakaoState(params.get('state'));
if (!ok) {
setError('로그인 요청이 확인되지 않았습니다. 다시 시도해 주세요.');
return;
}
void (async () => {
try {
// ★ 인가 때 쓴 주소를 그대로 보낸다 — 카카오가 교환 시점에 대조한다.
const res = await kakaoLogin({code, redirect_uri: KAKAO_REDIRECT_URI});
if (res.result?.success === false || !(await establishSession(res, ''))) {
setError('카카오 로그인에 실패했습니다. 다시 시도해 주세요.');
return;
}
navigate(returnTo, {replace: true});
} catch {
setError('카카오 로그인에 실패했습니다. 다시 시도해 주세요.');
}
})();
}, [params, navigate]);
return (
<main className="flex min-h-screen items-center justify-center p-6">
{error ? (
<div className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7 text-center">
<p className="text-sm">{error}</p>
<button
type="button"
className="text-xs font-semibold underline"
onClick={() => navigate('/login', {replace: true})}
>
로그인 화면으로
</button>
</div>
) : (
<p className="text-sm text-muted-foreground">카카오 계정으로 로그인하는 중…</p>
)}
</main>
);
}

View File

@ -7,6 +7,8 @@ export default [
route('approve/:postId', 'pages/SocialApprovalPage.tsx'), route('approve/:postId', 'pages/SocialApprovalPage.tsx'),
route('login', 'pages/LoginPage.tsx'), route('login', 'pages/LoginPage.tsx'),
// 카카오 인가 화면이 돌려보내는 자리. 카카오 콘솔의 Redirect URI 와 **같은 경로**여야 한다.
route('auth/kakao/callback', 'pages/KakaoCallbackPage.tsx'),
// 로그인 화면의 [회원가입] 이 여기로 온다. // 로그인 화면의 [회원가입] 이 여기로 온다.
route('signup', 'pages/SignupPage.tsx'), route('signup', 'pages/SignupPage.tsx'),

View File

@ -9,6 +9,10 @@ interface ImportMetaEnv {
readonly VITE_AUTO_LOGIN_PW?: string; readonly VITE_AUTO_LOGIN_PW?: string;
/** 구글 OAuth 클라이언트 ID. */ /** 구글 OAuth 클라이언트 ID. */
readonly VITE_GOOGLE_CLIENT_ID?: string; readonly VITE_GOOGLE_CLIENT_ID?: string;
/** 카카오 REST API 키. 인가 URL 의 client_id 다 — 시크릿은 프론트에 오지 않는다. */
readonly VITE_KAKAO_REST_API_KEY?: string;
/** 카카오 Redirect URI. 콘솔 등록값·백엔드 설정값과 셋이 같아야 한다. */
readonly VITE_KAKAO_REDIRECT_URI?: string;
} }
interface ImportMeta { interface ImportMeta {