[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 로 흘려보낸다. 두 곳에 따로 적지 않는다.
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
# 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다).
# 카카오 개발자 콘솔 > 내 애플리케이션 > 앱 키(REST API 키) · 카카오 로그인 > 보안(client_secret)
# ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은
# 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요).
# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다.
KAKAO_LOGIN_APP_ID=
# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. 그래서 아래 값은 KAKAO_BOT_REST_API_KEY
# 와 같은 값이고, 어긋나면 서버가 로그에 경고를 남긴다(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=

View File

@ -24,6 +24,12 @@ x-common-env: &common-env
# ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
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 이 전부 이걸 쓴다.
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
@ -190,6 +196,9 @@ services:
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
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:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@ -260,6 +269,9 @@ services:
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 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 를 아예 안 받는다.
# 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다.
image: o2o-web4ai-solution-site

View File

@ -30,10 +30,17 @@ ARG VITE_SITE_PREVIEW_URL
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
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 \
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
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 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는
# 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나
# JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,42 +1,59 @@
"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다.
"""카카오 로그인 — 인가 코드를 우리 서버가 토큰으로 바꾸고, 그 토큰으로 신원을 확인한다.
★ 구글(google_identity)과 검증 방식이 다르다. 구글은 ID 토큰을 **우리가 직접** 서명·수신자까지
뜯어보지만, 카카오는 액세스 토큰을 카카오에 되물어 확인한다(`/v1/user/access_token_info`).
그 응답의 `app_id` 가 우리 앱인지 대조하는 것이 구글의 `aud` 검사에 해당한다 —
**이 검사가 유일하게 "남의 앱에 발급된 진짜 카카오 토큰" 을 막는다.**
★ 구글과 흐름이 다르다. 구글은 프론트가 받은 ID 토큰을 우리가 검증하지만(GIS),
카카오는 **클라이언트 시크릿이 있으면 토큰 교환을 서버가 해야 한다** — 시크릿을 브라우저에
둘 수 없기 때문이다. 그래서 프론트는 `code` 만 넘기고 교환은 여기서 한다.
★★ 여기서 얻는 회원번호(`id`)가 **챗봇 웹훅의 `user.properties.appUserId` 와 같은 값**이다
(카카오 공식 문서: "앱 키가 정상적으로 등록된 경우, 카카오 로그인으로 받는 값과 동일").
그래서 봇에 물린 앱과 **같은 앱**이어야 채널 매칭이 된다 — 앱이 다르면 로그인은 되는데
매칭만 조용히 안 된다.
★★ 그래서 "남의 앱 토큰" 문제가 구조적으로 사라진다. 우리 REST 키·시크릿으로 교환한
토큰이므로 정의상 우리 앱 것이다 — 구글에서 `aud` 를 대조하던 자리가 여기서는 필요 없다.
★★ **로그인 앱과 챗봇 앱이 같아야 한다.** 회원번호(`id`)가 챗봇 웹훅의
`user.properties.appUserId` 와 같은 값이라, 같은 앱일 때만 채널 발화자와 이어진다.
다르면 **로그인은 되는데 매칭만 조용히 안 된다** — 그래서 아래에서 키가 어긋나면 경고를 남긴다.
"""
from dataclasses import dataclass
import contextlib
import httpx
from common.logger import LOG
from config import agent_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"
_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):
"""KAKAO_LOGIN_APP_ID 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다)."""
"""REST 키·리다이렉트 URI 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다)."""
class KakaoTokenInvalid(RuntimeError):
"""만료·위조이거나 **남의 앱에 발급된** 토큰이다."""
"""인가 코드가 만료·재사용됐거나 리다이렉트 URI 가 콘솔 등록값과 다르다."""
@dataclass
class KakaoAccount:
"""토큰으로 확인한 신원.
★ uid 가 판정 키다. 이메일은 동의 항목이라 **없을 수 있고** 바뀔 수도 있다 —
★ uid 가 판정 키다. 이메일은 **동의 항목이라 없을 수 있고** 바뀔 수도 있다 —
구글에서 sub 를 쓰는 것과 같은 이유다."""
uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다.
@ -45,49 +62,95 @@ class KakaoAccount:
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():
raise KakaoNotConfigured("KAKAO_LOGIN_APP_ID 가 설정되지 않았다")
if not access_token:
raise KakaoTokenInvalid("빈 토큰")
raise KakaoNotConfigured("KAKAO_LOGIN_REST_API_KEY / REDIRECT_URI 가 설정되지 않았다")
if not code:
raise KakaoTokenInvalid("빈 인가 코드")
_warn_if_not_same_app()
headers = {"Authorization": f"Bearer {access_token}"}
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
form = {
"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:
info = await client.get(_TOKEN_INFO_URL, headers=headers)
res = await http.post(_TOKEN_URL, data=form)
except httpx.HTTPError as ex:
# 카카오에 못 닿은 것과 토큰이 틀린 것은 다르다 — 로그로 가른다.
LOG.w(f"[kakao-login] 토큰 확인 실패: {type(ex).__name__}")
raise KakaoTokenInvalid("토큰 확인 실패") from ex
if info.status_code != 200:
raise KakaoTokenInvalid(f"토큰이 유효하지 않다({info.status_code})")
LOG.w(f"[kakao-login] 토큰 교환 실패: {type(ex).__name__}")
raise KakaoTokenInvalid("토큰 교환 실패") from ex
data = info.json()
# ★ 구글의 aud 검사에 해당한다. 이게 없으면 남의 앱 토큰으로 우리 계정이 된다.
if str(data.get("app_id") or "") != str(kakao_login_config.app_id):
LOG.w("[kakao-login] 다른 앱에 발급된 토큰 — 거절")
raise KakaoTokenInvalid("우리 앱의 토큰이 아니다")
uid = str(data.get("id") or "")
if not uid:
raise KakaoTokenInvalid("회원번호가 없다")
# 이름·이메일은 동의 항목이라 못 받을 수 있다. 못 받아도 로그인은 되어야 한다 —
# 판정 키는 회원번호뿐이고 나머지는 화면에 보여 줄 값일 뿐이다.
email = name = ""
if res.status_code != 200:
# ★ 응답 본문에 code·시크릿이 실려 있을 수 있어 그대로 남기지 않는다. 카카오가 주는
# error 코드만 남긴다 — 원인 대부분이 KOE006(리다이렉트 불일치)·코드 재사용이다.
try:
me = await client.get(_USER_ME_URL, headers=headers)
if me.status_code == 200:
body = me.json()
account = body.get("kakao_account") or {}
email = (account.get("email") or "") if account.get("is_email_valid") is not False else ""
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__}")
reason = (res.json() or {}).get("error") or res.status_code
except ValueError:
reason = res.status_code
LOG.w(f"[kakao-login] 토큰 교환 거절: {reason}")
raise KakaoTokenInvalid(f"인가 코드로 토큰을 받지 못했다({reason})")
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)
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 {
HTTPValidationError,
ReqGoogleLogin,
ReqKakaoLogin,
ReqLogin,
ReqSignup,
ReqUpdateMe,
@ -311,6 +312,98 @@ export const useGoogleLogin = <
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 토큰을 재발급한다.
* @summary 액세스 토큰 갱신

View File

@ -176,14 +176,7 @@ export function useHealthzHealthzGet<
}
/**
* ★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200),
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다.
* healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
* @summary Readyz
*/
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];
/**
* 인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는
것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다).
* 인증을 요구하지 않는다.
* @summary 사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다
*/
export const relay = (

View File

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

View File

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

View File

@ -6,7 +6,7 @@
*/
/**
* site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다.
* site_versions.build_status 코드값.
*/
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";
/**
* 모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다.
* 모든 응답에 공통으로 실리는 결과 정보.
*/
export interface ErrorInfo {
success?: ErrorInfoSuccess;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@
*/
/**
* local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류.
* local_contents.content_type 코드값.
*/
export type 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];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,10 +9,7 @@ import type { RenderStatusDataRenderedVersion } from "./renderStatusDataRendered
import type { RenderStatusDataError } from "./renderStatusDataError";
/**
* 정적 페이지가 실제로 구워졌는지. 프리렌더가 남긴 보고서를 그대로 옮긴다.
★ 발행 기록(DB)과 실제 페이지(파일)는 다른 곳에 산다. 이게 없으면 프리렌더가 깨져도
DB 는 "발행됨"이라 말하고 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
* 정적 페이지가 실제로 구워졌는지.
*/
export interface RenderStatusData {
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.
★ generated_by 를 요청으로 받지 않는다 — 받으면 LLM 생성물을 사람이 쓴 것처럼 올려
승인 절차를 통째로 건너뛸 수 있다. 출처는 서버가 OWNER 로 고정한다.
*/
export interface ReqCreateFaq {
question?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,32 +7,7 @@
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 {
theme?: ReqSiteThemeTheme;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@
*/
/**
* sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다.
* sites.status 코드값.
*/
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 공용 코드값.
값이 어디서 왔는지 — 모든 사실은 출처를 갖는다.
*/
export type SourceType = (typeof SourceType)[keyof typeof SourceType];

View File

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

View File

@ -23,6 +23,7 @@ import type {
import type {
ApprovePageParams,
CheckSlugParams,
EditRedirectParams,
GenerateMyPostForDateParams,
GenerateMyPostsParams,
HTTPValidationError,
@ -613,7 +614,7 @@ export const useSetSlug = <
return useMutation(mutationOptions, queryClient);
};
/**
* 위저드에서 고른 템플릿을 sites.template_id 에 저장한다(사이트 행이 없으면 만든다). ★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. 길이(100자)만 막는다. ★ 주소와 달리 발행 뒤에도 바꿀 수 있다: 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않는다. 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).
* sites.template_id 에 저장한다(사이트 행이 없으면 만든다). 업종 허용 목록에 없는 id는 거절한다. 이미 발행된 사이트면 재빌드 표시(content_updated_at)를 찍는다.
* @summary 템플릿(디자인) 선택 저장
*/
export const setTemplate = (
@ -706,8 +707,8 @@ export const useSetTemplate = <
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).
* @summary 디자인(색·서체·섹션) 저장
* sites.theme 에 저장한다(사이트 행이 없으면 만든다). body: {"theme":{"colors","look","colorPaletteId","sections":[{"id","name","enabled","locked","body","data"}]}}. 배열 순서가 곧 섹션 순서다. 크기(64KB)만 막는다. 빈 값({})이면 업종 기본으로 되돌린다.
* @summary 디자인(색·섹션) 저장
*/
export const setTheme = (
placeId: string,
@ -772,7 +773,7 @@ export type SetThemeMutationBody = ReqSiteTheme;
export type SetThemeMutationError = void | HTTPValidationError;
/**
* @summary 디자인(색·서체·섹션) 저장
* @summary 디자인(색·섹션) 저장
*/
export const useSetTheme = <
TError = void | HTTPValidationError,
@ -1752,6 +1753,153 @@ export const useSendBookingRequest = <
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 승인 확정 — 누르는 즉시 게재 큐에 넣는다
*/
@ -3183,8 +3331,7 @@ export const useSubmitReview = <
return useMutation(mutationOptions, queryClient);
};
/**
* 날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고,
화면은 붙은 뒤 이 주소로 최신을 받아 덮는다.
* 날씨(/v1/local/weather)와 같은 공개 조회다.
* @summary 게재된 후기 — 발행본이 붙은 뒤 받아 간다
*/
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 {googleLogin, login} from '@/api';
import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton';
import {KakaoSignInButton} from '@/components/auth/KakaoSignInButton';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify';
import {isGoogleLoginEnabled} from '@/lib/googleIdentity';
import {isKakaoLoginEnabled} from '@/lib/kakaoIdentity';
import {establishSession} from '@/lib/session';
interface SignInFormProps {
@ -107,14 +109,17 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
</Button>
{/* 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다. */}
{isGoogleLoginEnabled() && (
{(isKakaoLoginEnabled() || isGoogleLoginEnabled()) && (
<>
<div className="flex items-center gap-2">
<span className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" />
</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('login', 'pages/LoginPage.tsx'),
// 카카오 인가 화면이 돌려보내는 자리. 카카오 콘솔의 Redirect URI 와 **같은 경로**여야 한다.
route('auth/kakao/callback', 'pages/KakaoCallbackPage.tsx'),
// 로그인 화면의 [회원가입] 이 여기로 온다.
route('signup', 'pages/SignupPage.tsx'),

View File

@ -9,6 +9,10 @@ interface ImportMetaEnv {
readonly VITE_AUTO_LOGIN_PW?: string;
/** 구글 OAuth 클라이언트 ID. */
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 {