[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:
parent
ca0bea77a7
commit
3253166622
14
.env.example
14
.env.example
@ -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=
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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).",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
153
solution/backend/services/external/kakao_identity.py
vendored
153
solution/backend/services/external/kakao_identity.py
vendored
@ -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] 다른 앱에 발급된 토큰 — 거절")
|
try:
|
||||||
raise KakaoTokenInvalid("우리 앱의 토큰이 아니다")
|
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})")
|
||||||
|
|
||||||
uid = str(data.get("id") or "")
|
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:
|
if not uid:
|
||||||
raise KakaoTokenInvalid("회원번호가 없다")
|
raise KakaoTokenInvalid("회원번호가 없다")
|
||||||
|
|
||||||
# 이름·이메일은 동의 항목이라 못 받을 수 있다. 못 받아도 로그인은 되어야 한다 —
|
# 이름·이메일은 동의 항목이다. 못 받아도 계정은 만들어야 한다 — 판정 키는 회원번호뿐이고
|
||||||
# 판정 키는 회원번호뿐이고 나머지는 화면에 보여 줄 값일 뿐이다.
|
# 나머지는 화면에 보여 줄 값일 뿐이다.
|
||||||
email = name = ""
|
|
||||||
try:
|
|
||||||
me = await client.get(_USER_ME_URL, headers=headers)
|
|
||||||
if me.status_code == 200:
|
|
||||||
body = me.json()
|
|
||||||
account = body.get("kakao_account") or {}
|
account = body.get("kakao_account") or {}
|
||||||
email = (account.get("email") or "") if account.get("is_email_valid") is not False else ""
|
# ★ 확인되지 않은 이메일은 신원으로 쓰지 않는다 — 남의 주소를 적어 둔 계정일 수 있고,
|
||||||
|
# `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")
|
name = ((account.get("profile") or {}).get("nickname")
|
||||||
or (body.get("properties") or {}).get("nickname") or "")
|
or (body.get("properties") or {}).get("nickname") or "")
|
||||||
except httpx.HTTPError as ex:
|
|
||||||
LOG.w(f"[kakao-login] 프로필 조회 실패(로그인은 계속): {type(ex).__name__}")
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
139
solution/backend/tests/test_kakao_identity.py
Normal file
139
solution/backend/tests/test_kakao_identity.py
Normal 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"
|
||||||
710
solution/frontend/src/api/generated/agent/agent.ts
Normal file
710
solution/frontend/src/api/generated/agent/agent.ts
Normal 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);
|
||||||
|
};
|
||||||
@ -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 액세스 토큰 갱신
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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);
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
19
solution/frontend/src/api/generated/model/confirm.ts
Normal file
19
solution/frontend/src/api/generated/model/confirm.ts
Normal 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;
|
||||||
|
}
|
||||||
8
solution/frontend/src/api/generated/model/confirmArgs.ts
Normal file
8
solution/frontend/src/api/generated/model/confirmArgs.ts
Normal 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 };
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 —
|
* fact 기록 결과.
|
||||||
특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다.
|
|
||||||
*/
|
*/
|
||||||
export type FactWriteOutcome =
|
export type FactWriteOutcome =
|
||||||
(typeof FactWriteOutcome)[keyof typeof FactWriteOutcome];
|
(typeof FactWriteOutcome)[keyof typeof FactWriteOutcome];
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
13
solution/frontend/src/api/generated/model/reqChat.ts
Normal file
13
solution/frontend/src/api/generated/model/reqChat.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -6,10 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다.
|
* 구글 로그인.
|
||||||
|
|
||||||
필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 —
|
|
||||||
`access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다.
|
|
||||||
*/
|
*/
|
||||||
export interface ReqGoogleLogin {
|
export interface ReqGoogleLogin {
|
||||||
credential?: string;
|
credential?: string;
|
||||||
|
|||||||
22
solution/frontend/src/api/generated/model/reqKakaoLogin.ts
Normal file
22
solution/frontend/src/api/generated/model/reqKakaoLogin.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -6,12 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예전 버전으로 공개 주소를 되돌린다.
|
* 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다.
|
||||||
|
|
||||||
★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면
|
|
||||||
(보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서
|
|
||||||
돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는
|
|
||||||
그대로 통과한다).
|
|
||||||
*/
|
*/
|
||||||
export interface ReqRollback {
|
export interface ReqRollback {
|
||||||
target_version: number;
|
target_version: number;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -7,9 +7,6 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 사이트 주소(네임스페이스) 예약.
|
* 사이트 주소(네임스페이스) 예약.
|
||||||
|
|
||||||
★ 서버가 상호명으로 자동 확정하지 않는다 — 사장님이 고른다.
|
|
||||||
주소는 AI 검색이 색인하는 영구 식별자라, 한 번 정해지면 되돌리는 비용이 사장님 몫이 된다.
|
|
||||||
*/
|
*/
|
||||||
export interface ReqSiteSlug {
|
export interface ReqSiteSlug {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -7,10 +7,6 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 템플릿 선택 저장.
|
* 템플릿 선택 저장.
|
||||||
|
|
||||||
★ 서버는 값을 검증하지 않는다. 템플릿 목록은 프론트(배리에이션 레지스트리)가 소유하므로
|
|
||||||
여기서 화이트리스트를 두면 템플릿을 하나 늘릴 때마다 백엔드를 같이 고쳐야 한다.
|
|
||||||
잘못된 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨린다 — 화면이 깨지지 않는다.
|
|
||||||
*/
|
*/
|
||||||
export interface ReqSiteTemplate {
|
export interface ReqSiteTemplate {
|
||||||
template_id?: string;
|
template_id?: string;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -7,9 +7,6 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 정적 빌드 시작.
|
* 정적 빌드 시작.
|
||||||
|
|
||||||
publish=true 면 발행 검수 게이트를 통과했을 때 바로 발행까지 한다.
|
|
||||||
★ 게이트를 통과하지 못하면 발행되지 않는다 — 우회 옵션은 없다.
|
|
||||||
*/
|
*/
|
||||||
export interface ReqStartBuild {
|
export interface ReqStartBuild {
|
||||||
publish?: boolean;
|
publish?: boolean;
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수집 시작. 몇 분 걸리므로 동기로 처리하지 않고 잡을 적재한 뒤 즉시 응답한다.
|
* 수집 시작.
|
||||||
*/
|
*/
|
||||||
export interface ReqStartCollect {
|
export interface ReqStartCollect {
|
||||||
link_ids?: string[];
|
link_ids?: string[];
|
||||||
|
|||||||
@ -7,8 +7,6 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 소개문·FAQ 생성 시작.
|
* 소개문·FAQ 생성 시작.
|
||||||
|
|
||||||
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다).
|
|
||||||
*/
|
*/
|
||||||
export interface ReqStartCopy {
|
export interface ReqStartCopy {
|
||||||
resume?: boolean;
|
resume?: boolean;
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사진 분석 시작. 사진 20~50장이라 몇 분 걸린다 — 잡으로 처리한다.
|
* 사진 분석 시작.
|
||||||
*/
|
*/
|
||||||
export interface ReqStartVision {
|
export interface ReqStartVision {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -7,15 +7,6 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 네이버 플레이스 URL 하나로 동일 업소를 확정한다.
|
* 네이버 플레이스 URL 하나로 동일 업소를 확정한다.
|
||||||
|
|
||||||
★ 왜 이 경로가 필요한가
|
|
||||||
상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울').
|
|
||||||
Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다.
|
|
||||||
그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장
|
|
||||||
정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다.
|
|
||||||
|
|
||||||
서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로
|
|
||||||
옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다).
|
|
||||||
*/
|
*/
|
||||||
export interface ReqVerifyPlaceByUrl {
|
export interface ReqVerifyPlaceByUrl {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|||||||
@ -10,9 +10,6 @@ import type { ResExtractedFact } from "./resExtractedFact";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 뽑힌 것과 버려진 것을 **둘 다** 돌려준다.
|
* 뽑힌 것과 버려진 것을 **둘 다** 돌려준다.
|
||||||
|
|
||||||
★ 조용히 버리지 않는다 — 사장님이 "내가 쓴 체크인 시간이 왜 안 들어갔지" 를
|
|
||||||
화면에서 바로 확인할 수 있어야 한다.
|
|
||||||
*/
|
*/
|
||||||
export interface ResExtractFacts {
|
export interface ResExtractFacts {
|
||||||
result?: ErrorInfo;
|
result?: ErrorInfo;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -11,9 +11,6 @@ import type { PlaceSearchItem } from "./placeSearchItem";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 상호명 공개 검색 결과.
|
* 상호명 공개 검색 결과.
|
||||||
|
|
||||||
★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 —
|
|
||||||
만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다.
|
|
||||||
*/
|
*/
|
||||||
export interface ResPlaceSearch {
|
export interface ResPlaceSearch {
|
||||||
result?: ErrorInfo;
|
result?: ErrorInfo;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -12,9 +12,6 @@ import type { PlaceCandidate } from "./placeCandidate";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 동일 업소 후보 목록.
|
* 동일 업소 후보 목록.
|
||||||
|
|
||||||
★ 자동 판정을 신뢰하지 않는다. outcome 이 MATCHED 여도 후보를 전부 내려보내
|
|
||||||
UI 가 사람에게 확인시킬 수 있게 한다 — 남의 가게가 섞이면 그게 제일 비싼 실수다.
|
|
||||||
*/
|
*/
|
||||||
export interface ResVerifyCandidates {
|
export interface ResVerifyCandidates {
|
||||||
result?: ErrorInfo;
|
result?: ErrorInfo;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다.
|
* sites.status 코드값.
|
||||||
*/
|
*/
|
||||||
export type SiteStatus = (typeof SiteStatus)[keyof typeof SiteStatus];
|
export type SiteStatus = (typeof SiteStatus)[keyof typeof SiteStatus];
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
31
solution/frontend/src/components/auth/KakaoSignInButton.tsx
Normal file
31
solution/frontend/src/components/auth/KakaoSignInButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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" />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
53
solution/frontend/src/lib/kakaoIdentity.ts
Normal file
53
solution/frontend/src/lib/kakaoIdentity.ts
Normal 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};
|
||||||
|
}
|
||||||
70
solution/frontend/src/pages/KakaoCallbackPage.tsx
Normal file
70
solution/frontend/src/pages/KakaoCallbackPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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'),
|
||||||
|
|
||||||
|
|||||||
4
solution/frontend/src/vite-env.d.ts
vendored
4
solution/frontend/src/vite-env.d.ts
vendored
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user