From 325316662251657799ec17c888bdd5a914e47d2f Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 30 Sep 2026 16:51:05 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution,deploy:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=84=20=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EC=BD=94=EB=93=9C=20=EA=B5=90=ED=99=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EC=8B=9C=ED=81=AC=EB=A6=BF=EC=9D=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=97=90=20=EA=B0=80=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 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) --- .env.example | 14 +- docker-compose.yml | 12 + nginx/Dockerfile | 9 +- solution/backend/common/enums.py | 2 +- solution/backend/config/config_models.py | 21 +- solution/backend/router/v1/auth/account.py | 3 +- solution/backend/router/v1/auth/protocol.py | 12 +- solution/backend/services/auth_service.py | 6 +- .../services/external/kakao_identity.py | 163 ++-- solution/backend/tests/test_kakao_identity.py | 139 ++++ .../frontend/src/api/generated/agent/agent.ts | 710 ++++++++++++++++++ .../frontend/src/api/generated/auth/auth.ts | 93 +++ .../src/api/generated/default/default.ts | 9 +- .../frontend/src/api/generated/image/image.ts | 3 +- .../generated/local-content/local-content.ts | 4 +- .../frontend/src/api/generated/media/media.ts | 188 ++++- .../src/api/generated/model/authProvider.ts | 7 +- .../src/api/generated/model/buildStatus.ts | 2 +- .../src/api/generated/model/confirm.ts | 19 + .../src/api/generated/model/confirmArgs.ts | 8 + .../api/generated/model/editRedirectParams.ts | 14 + .../src/api/generated/model/errorInfo.ts | 2 +- .../generated/model/externalPlaceSource.ts | 5 +- .../src/api/generated/model/factStatus.ts | 1 - .../api/generated/model/factWriteOutcome.ts | 3 +- .../frontend/src/api/generated/model/index.ts | 6 + .../src/api/generated/model/jobStatus.ts | 5 +- .../src/api/generated/model/jobType.ts | 5 +- .../src/api/generated/model/linkChannel.ts | 2 +- .../api/generated/model/localContentType.ts | 2 +- .../src/api/generated/model/localSource.ts | 2 +- .../src/api/generated/model/mediaData.ts | 5 - .../src/api/generated/model/mediaStatus.ts | 2 +- .../src/api/generated/model/mySiteData.ts | 3 - .../src/api/generated/model/opsSiteData.ts | 2 - .../src/api/generated/model/opsUserData.ts | 2 +- .../src/api/generated/model/placeCandidate.ts | 2 +- .../src/api/generated/model/placeCategory.ts | 3 +- .../api/generated/model/placeSearchItem.ts | 5 - .../src/api/generated/model/placeStatus.ts | 3 +- .../generated/model/publishRejectReason.ts | 2 +- .../api/generated/model/renderStatusData.ts | 5 +- .../src/api/generated/model/reqChat.ts | 13 + .../src/api/generated/model/reqChatConfirm.ts | 9 + .../src/api/generated/model/reqCreateFaq.ts | 3 - .../api/generated/model/reqExtractFacts.ts | 7 - .../src/api/generated/model/reqGoogleLogin.ts | 5 +- .../src/api/generated/model/reqKakaoLogin.ts | 22 + .../src/api/generated/model/reqRollback.ts | 7 +- .../src/api/generated/model/reqSignup.ts | 6 +- .../src/api/generated/model/reqSiteSlug.ts | 3 - .../src/api/generated/model/reqSiteStatus.ts | 8 +- .../api/generated/model/reqSiteTemplate.ts | 4 - .../src/api/generated/model/reqSiteTheme.ts | 27 +- .../src/api/generated/model/reqStartBuild.ts | 3 - .../api/generated/model/reqStartCollect.ts | 2 +- .../src/api/generated/model/reqStartCopy.ts | 2 - .../src/api/generated/model/reqStartVision.ts | 2 +- .../api/generated/model/reqTransitionFact.ts | 2 +- .../api/generated/model/reqTransitionFaq.ts | 4 +- .../src/api/generated/model/reqUpsertFact.ts | 4 +- .../src/api/generated/model/reqVerifyPlace.ts | 4 - .../generated/model/reqVerifyPlaceByUrl.ts | 9 - .../api/generated/model/resExtractFacts.ts | 3 - .../src/api/generated/model/resGenerateOne.ts | 4 +- .../src/api/generated/model/resJob.ts | 2 +- .../src/api/generated/model/resLocalGuide.ts | 3 +- .../src/api/generated/model/resPlaceSearch.ts | 3 - .../src/api/generated/model/resSiteSlug.ts | 2 +- .../src/api/generated/model/resSlugCheck.ts | 2 +- .../api/generated/model/resStartCollect.ts | 2 +- .../src/api/generated/model/resSyncPlace.ts | 2 +- .../generated/model/resVerifyCandidates.ts | 3 - .../src/api/generated/model/showcaseItem.ts | 6 +- .../src/api/generated/model/siteStatus.ts | 2 +- .../src/api/generated/model/sourceType.ts | 1 - .../src/api/generated/model/userRole.ts | 2 - .../frontend/src/api/generated/site/site.ts | 159 +++- .../src/components/auth/KakaoSignInButton.tsx | 31 + .../frontend/src/features/auth/SignInForm.tsx | 9 +- solution/frontend/src/lib/kakaoIdentity.ts | 53 ++ .../frontend/src/pages/KakaoCallbackPage.tsx | 70 ++ solution/frontend/src/routes.ts | 2 + solution/frontend/src/vite-env.d.ts | 4 + 84 files changed, 1757 insertions(+), 259 deletions(-) create mode 100644 solution/backend/tests/test_kakao_identity.py create mode 100644 solution/frontend/src/api/generated/agent/agent.ts create mode 100644 solution/frontend/src/api/generated/model/confirm.ts create mode 100644 solution/frontend/src/api/generated/model/confirmArgs.ts create mode 100644 solution/frontend/src/api/generated/model/editRedirectParams.ts create mode 100644 solution/frontend/src/api/generated/model/reqChat.ts create mode 100644 solution/frontend/src/api/generated/model/reqChatConfirm.ts create mode 100644 solution/frontend/src/api/generated/model/reqKakaoLogin.ts create mode 100644 solution/frontend/src/components/auth/KakaoSignInButton.tsx create mode 100644 solution/frontend/src/lib/kakaoIdentity.ts create mode 100644 solution/frontend/src/pages/KakaoCallbackPage.tsx diff --git a/.env.example b/.env.example index ce32b44..b6006c3 100644 --- a/.env.example +++ b/.env.example @@ -120,10 +120,20 @@ KAKAO_LINK_MAX_ATTEMPTS=5 # VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다. # ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site # 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다). +# 카카오 개발자 콘솔 > 내 애플리케이션 > 앱 키(REST API 키) · 카카오 로그인 > 보안(client_secret) # ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 # 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요). -# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. -KAKAO_LOGIN_APP_ID= +# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. 그래서 아래 값은 KAKAO_BOT_REST_API_KEY +# 와 같은 값이고, 어긋나면 서버가 로그에 경고를 남긴다(services/external/kakao_identity.py). +KAKAO_LOGIN_REST_API_KEY= +# ★ 이건 비밀이다. 그래서 프론트가 토큰을 받는 구조를 못 쓰고 **인가 코드를 서버가 교환**한다. +KAKAO_LOGIN_CLIENT_SECRET= +# ★ 콘솔의 [카카오 로그인 > Redirect URI] 에 등록한 값과 **글자 하나까지** 같아야 한다. +# 다르면 KOE006 이고 화면에는 그냥 "로그인 실패" 로만 보인다. +# ★ 프론트(VITE_KAKAO_REDIRECT_URI)와 같은 값이어야 한다 — compose 가 흘려보낸다. +# 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site +KAKAO_LOGIN_REDIRECT_URI= +# ★ 어드민 키는 적지 않는다. 계정을 지우는 것까지 되는 키인데 로그인에는 쓰이지 않는다. GOOGLE_CLIENT_ID= diff --git a/docker-compose.yml b/docker-compose.yml index d37cc6d..514e6bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,12 @@ x-common-env: &common-env # ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의 # 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + # ★ 카카오 로그인은 시크릿 때문에 **서버가 인가 코드를 교환**한다 — 프론트에는 REST 키와 + # 리다이렉트 주소만 간다(둘 다 공개값이다). 시크릿은 여기서 밖으로 나가지 않는다. + # ★★ KAKAO_BOT_REST_API_KEY 와 **같은 값**이어야 카톡 채널 발화자와 계정이 이어진다. + KAKAO_LOGIN_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-} + KAKAO_LOGIN_CLIENT_SECRET: ${KAKAO_LOGIN_CLIENT_SECRET:-} + KAKAO_LOGIN_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-} # ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다. # ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가 # 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다). @@ -190,6 +196,9 @@ services: VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} # 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처). VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + # 인가 URL 을 만드는 데 쓴다. 시크릿은 넘기지 않는다 — 교환은 백엔드가 한다. + VITE_KAKAO_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-} + VITE_KAKAO_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-} volumes: - ./package.json:/app/package.json - ./package-lock.json:/app/package-lock.json @@ -260,6 +269,9 @@ services: VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost} # 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다. VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + # 비어 있으면 카카오 로그인 버튼이 안 뜬다. 인가 URL 만 만든다 — 시크릿은 여기 없다. + VITE_KAKAO_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-} + VITE_KAKAO_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-} # ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다. # 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다. image: o2o-web4ai-solution-site diff --git a/nginx/Dockerfile b/nginx/Dockerfile index e823055..72b44c4 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -30,10 +30,17 @@ ARG VITE_SITE_PREVIEW_URL # 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와 # 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다). ARG VITE_GOOGLE_CLIENT_ID +# 카카오 REST API 키와 Redirect URI. 둘 다 비밀이 아니다 — 인가 URL 에 그대로 실려 나간다. +# ★ 클라이언트 시크릿은 여기 없다. 인가 코드를 토큰으로 바꾸는 일은 **백엔드가 한다** — +# 시크릿이 번들에 구워지면 누구나 우리 앱 이름으로 토큰을 받을 수 있다. +ARG VITE_KAKAO_REST_API_KEY +ARG VITE_KAKAO_REDIRECT_URI ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \ - VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID + VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID \ + VITE_KAKAO_REST_API_KEY=$VITE_KAKAO_REST_API_KEY \ + VITE_KAKAO_REDIRECT_URI=$VITE_KAKAO_REDIRECT_URI # ★ VITE_AUTO_LOGIN_ID·PW 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는 # 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나 # JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev, diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 3d1a41c..0751a09 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -160,7 +160,7 @@ class AuthProvider(CodeEnum): LOCAL = 1 # id/pw GOOGLE = 2 # 구글 ID 토큰 - KAKAO = 3 # 카카오 액세스 토큰 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다 + KAKAO = 3 # 카카오 인가 코드 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다 class CompanyStatus(CodeEnum): diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index 5271deb..adc3317 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -103,18 +103,29 @@ class GoogleOAuthConfig(BaseSettings): class KakaoLoginConfig(BaseSettings): - """카카오 로그인. app_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. + """카카오 로그인. 값이 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. ★★ **챗봇에 물린 앱과 같은 앱이어야 한다.** 회원번호(`id`)가 챗봇 웹훅의 `user.properties.appUserId` 와 같은 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 바로 이어진다(6자리 코드 절차가 필요 없어진다). 앱이 다르면 **로그인은 되는데 매칭만 - 조용히 안 된다** — 증상이 안 보이는 종류라 여기 적어 둔다. - ★ app_id 는 비밀이 아니다(토큰 응답에 실려 온다). 서버가 이 값을 갖는 이유는 숨기려는 게 - 아니라 **발급처 대조** 때문이다 — 구글의 `aud` 검사와 같은 자리다.""" + 조용히 안 된다** — 증상이 안 보이는 종류라, `kakao_identity` 가 값이 어긋나면 경고를 남긴다. + + ★ 그래서 `rest_api_key` 는 `agent_config.KAKAO_BOT_REST_API_KEY` 와 **같은 값**이다. + AGENTS.md 의 "두 곳에 같은 값을 적지 않는다" 를 여기서만 깨는 이유: 챗봇 설정과 로그인 + 설정은 수명이 다르고(한쪽만 갈아 끼우는 일이 실제로 생긴다), 한 칸으로 합치면 어긋났을 때 + **어긋났다는 사실 자체를 볼 수 없다.** 따로 두고 대조해 경고를 내는 쪽을 택했다. + + ★ `client_secret` 은 비밀이다. 그래서 프론트가 토큰을 받아 오는 방식을 못 쓰고 + **인가 코드를 서버가 교환**한다 — 시크릿을 브라우저에 둘 수 없기 때문이다.""" model_config = _BASE - app_id: str = Field("", validation_alias="KAKAO_LOGIN_APP_ID") + rest_api_key: str = Field("", validation_alias="KAKAO_LOGIN_REST_API_KEY") + client_secret: str = Field("", validation_alias="KAKAO_LOGIN_CLIENT_SECRET") + # ★ 콘솔에 등록한 값과 **글자 하나까지** 같아야 한다. 다르면 KOE006 이고 화면에는 그냥 + # "로그인 실패" 로만 보인다. 프론트의 인가 요청과 서버의 교환이 같은 값을 써야 하므로 + # compose 가 이 값을 VITE_KAKAO_REDIRECT_URI 로 흘려보낸다. + redirect_uri: str = Field("", validation_alias="KAKAO_LOGIN_REDIRECT_URI") class ExternalApiConfig(BaseSettings): diff --git a/solution/backend/router/v1/auth/account.py b/solution/backend/router/v1/auth/account.py index 751e6f4..f3ad021 100644 --- a/solution/backend/router/v1/auth/account.py +++ b/solution/backend/router/v1/auth/account.py @@ -44,7 +44,8 @@ async def google_login(req: Req_GoogleLogin, service: AuthService = Depends()): path="/kakao", response_model=Res_Login, summary="카카오 로그인", - description="카카오 **액세스 토큰**을 카카오에 되물어 검증하고 JWT 를 발급한다. 처음 온 계정은 그 자리에서 만든다. " + description="카카오 **인가 코드**를 서버가 토큰으로 교환해 신원을 확인하고 JWT 를 발급한다. " + "처음 온 계정은 그 자리에서 만든다. ★ 코드는 1회용이라 같은 값으로 두 번 부르면 실패한다. " "★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, " "그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md).", ) diff --git a/solution/backend/router/v1/auth/protocol.py b/solution/backend/router/v1/auth/protocol.py index fcba08b..283bc81 100644 --- a/solution/backend/router/v1/auth/protocol.py +++ b/solution/backend/router/v1/auth/protocol.py @@ -32,10 +32,16 @@ class Req_GoogleLogin(AuthProtocol): class Req_KakaoLogin(AuthProtocol): """카카오 로그인. - ★ 구글은 ID 토큰(credential)을 우리가 직접 검증하지만, 카카오는 **액세스 토큰**을 - 카카오에 되물어 확인한다 — 그래서 필드 이름이 다르다.""" + ★ 구글은 브라우저가 받은 ID 토큰(credential)을 우리가 검증하지만, 카카오는 **인가 코드**를 + 받아 서버가 토큰으로 바꾼다 — 클라이언트 시크릿을 브라우저에 둘 수 없기 때문이다. + 그래서 필드 이름이 다르다. - access_token: str = "" + ★ `redirect_uri` 는 프론트가 인가를 요청할 때 쓴 값이다. 카카오가 교환 시점에 이 값을 + 대조하므로 **글자 하나까지 같아야** 한다. 비우면 서버 설정값을 쓴다 — 한 벌로 쓰는 + 평소에는 보낼 필요가 없고, 로컬처럼 주소가 다른 환경에서만 채운다.""" + + code: str = "" + redirect_uri: str = "" class Res_Login(Res_WebPacketProtocol): diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py index 4cd9c7d..b0a428d 100644 --- a/solution/backend/services/auth_service.py +++ b/solution/backend/services/auth_service.py @@ -29,7 +29,7 @@ from services.external.kakao_identity import ( KakaoAccount, KakaoNotConfigured, KakaoTokenInvalid, - verify_access_token as verify_kakao_token, + verify_code as verify_kakao_code, ) from services.external.google_identity import ( GoogleAccount, @@ -261,7 +261,7 @@ class AuthService: return await self._finish_login(user) async def kakao_login(self, req: Req_KakaoLogin) -> Res_Login: - """카카오 액세스 토큰 → 우리 세션. + """카카오 인가 코드 → 우리 세션. ★ google_login 과 **같은 세 갈래**다(기존 계정 → 이메일 충돌 → 신규). 규칙을 한 벌로 유지하려고 모양을 맞췄다 — 여기만 다르게 두면 "어느 쪽이 맞나" 를 매번 되짚게 된다. @@ -271,7 +271,7 @@ class AuthService: res = Res_Login() try: - account: KakaoAccount = await verify_kakao_token(req.access_token) + account: KakaoAccount = await verify_kakao_code(req.code, req.redirect_uri) except KakaoNotConfigured: res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED) return res diff --git a/solution/backend/services/external/kakao_identity.py b/solution/backend/services/external/kakao_identity.py index a8f632b..f5ace23 100644 --- a/solution/backend/services/external/kakao_identity.py +++ b/solution/backend/services/external/kakao_identity.py @@ -1,42 +1,59 @@ -"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다. +"""카카오 로그인 — 인가 코드를 우리 서버가 토큰으로 바꾸고, 그 토큰으로 신원을 확인한다. -★ 구글(google_identity)과 검증 방식이 다르다. 구글은 ID 토큰을 **우리가 직접** 서명·수신자까지 - 뜯어보지만, 카카오는 액세스 토큰을 카카오에 되물어 확인한다(`/v1/user/access_token_info`). - 그 응답의 `app_id` 가 우리 앱인지 대조하는 것이 구글의 `aud` 검사에 해당한다 — - **이 검사가 유일하게 "남의 앱에 발급된 진짜 카카오 토큰" 을 막는다.** +★ 구글과 흐름이 다르다. 구글은 프론트가 받은 ID 토큰을 우리가 검증하지만(GIS), + 카카오는 **클라이언트 시크릿이 있으면 토큰 교환을 서버가 해야 한다** — 시크릿을 브라우저에 + 둘 수 없기 때문이다. 그래서 프론트는 `code` 만 넘기고 교환은 여기서 한다. -★★ 여기서 얻는 회원번호(`id`)가 **챗봇 웹훅의 `user.properties.appUserId` 와 같은 값**이다 - (카카오 공식 문서: "앱 키가 정상적으로 등록된 경우, 카카오 로그인으로 받는 값과 동일"). - 그래서 봇에 물린 앱과 **같은 앱**이어야 채널 매칭이 된다 — 앱이 다르면 로그인은 되는데 - 매칭만 조용히 안 된다. +★★ 그래서 "남의 앱 토큰" 문제가 구조적으로 사라진다. 우리 REST 키·시크릿으로 교환한 + 토큰이므로 정의상 우리 앱 것이다 — 구글에서 `aud` 를 대조하던 자리가 여기서는 필요 없다. + +★★ **로그인 앱과 챗봇 앱이 같아야 한다.** 회원번호(`id`)가 챗봇 웹훅의 + `user.properties.appUserId` 와 같은 값이라, 같은 앱일 때만 채널 발화자와 이어진다. + 다르면 **로그인은 되는데 매칭만 조용히 안 된다** — 그래서 아래에서 키가 어긋나면 경고를 남긴다. """ from dataclasses import dataclass +import contextlib + import httpx from common.logger import LOG +from config import agent_config from config.server_configs import kakao_login_config -_TOKEN_INFO_URL = "https://kapi.kakao.com/v1/user/access_token_info" +_TOKEN_URL = "https://kauth.kakao.com/oauth/token" _USER_ME_URL = "https://kapi.kakao.com/v2/user/me" -_HTTP_TIMEOUT_SEC = 5.0 +_HTTP_TIMEOUT_SEC = 7.0 + + +@contextlib.asynccontextmanager +async def _session(client: httpx.AsyncClient | None): + """★ `kakao_event.send` 는 클라이언트를 호출부가 넘기게 했지만 여기서는 선택이다. + 저쪽은 한 요청에 여러 사장님에게 보내는 자리라 연결을 재사용해야 하고, 로그인은 사람 한 명이 + 한 번 누르는 일이다 — 호출부(auth_service)에 httpx 를 끌고 들어가지 않는 쪽을 택했다. + 테스트가 MockTransport 를 물릴 수 있도록 인자는 열어 둔다.""" + if client is not None: + yield client + return + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as owned: + yield owned class KakaoNotConfigured(RuntimeError): - """KAKAO_LOGIN_APP_ID 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다).""" + """REST 키·리다이렉트 URI 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다).""" class KakaoTokenInvalid(RuntimeError): - """만료·위조이거나 **남의 앱에 발급된** 토큰이다.""" + """인가 코드가 만료·재사용됐거나 리다이렉트 URI 가 콘솔 등록값과 다르다.""" @dataclass class KakaoAccount: """토큰으로 확인한 신원. - ★ uid 가 판정 키다. 이메일은 동의 항목이라 **없을 수 있고** 바뀔 수도 있다 — + ★ uid 가 판정 키다. 이메일은 **동의 항목이라 없을 수 있고** 바뀔 수도 있다 — 구글에서 sub 를 쓰는 것과 같은 이유다.""" uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다. @@ -45,49 +62,95 @@ class KakaoAccount: def is_configured() -> bool: - return bool(kakao_login_config.app_id) + return bool(kakao_login_config.rest_api_key and kakao_login_config.redirect_uri) -async def verify_access_token(access_token: str) -> KakaoAccount: - """액세스 토큰 → 신원. 실패는 두 예외 중 하나로만 나간다.""" +def _warn_if_not_same_app() -> None: + """★ 챗봇과 다른 앱이면 로그인은 되는데 채널 매칭만 조용히 안 된다. 증상이 안 보이는 + 종류라, 값이 어긋나면 로그로라도 남긴다.""" + bot_key = agent_config.get("KAKAO_BOT_REST_API_KEY") + if bot_key and bot_key != kakao_login_config.rest_api_key: + LOG.w("[kakao-login] 로그인 앱과 챗봇 앱의 REST 키가 다르다 — 채널 자동 매칭이 안 된다") + + +async def exchange_code(code: str, redirect_uri: str = "", *, + client: httpx.AsyncClient | None = None) -> str: + """인가 코드 → 액세스 토큰. + + ★ 리다이렉트 URI 는 **인가를 요청할 때 쓴 값과 글자 하나까지 같아야** 한다. 카카오가 + 그걸로 대조하기 때문이다 — 다르면 `KOE006` 으로 떨어지고, 화면에는 그냥 로그인 실패로만 + 보인다. 그래서 설정값 하나를 단일 출처로 두고 호출측이 덮어쓸 수 있게만 했다.""" if not is_configured(): - raise KakaoNotConfigured("KAKAO_LOGIN_APP_ID 가 설정되지 않았다") - if not access_token: - raise KakaoTokenInvalid("빈 토큰") + raise KakaoNotConfigured("KAKAO_LOGIN_REST_API_KEY / REDIRECT_URI 가 설정되지 않았다") + if not code: + raise KakaoTokenInvalid("빈 인가 코드") + _warn_if_not_same_app() - headers = {"Authorization": f"Bearer {access_token}"} - async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: + form = { + "grant_type": "authorization_code", + "client_id": kakao_login_config.rest_api_key, + "redirect_uri": redirect_uri or kakao_login_config.redirect_uri, + "code": code, + } + # 콘솔에서 시크릿을 켰으면 필수다. 안 켰으면 보내도 무시된다 — 양쪽 다 도는 쪽으로 둔다. + if kakao_login_config.client_secret: + form["client_secret"] = kakao_login_config.client_secret + + async with _session(client) as http: try: - info = await client.get(_TOKEN_INFO_URL, headers=headers) + res = await http.post(_TOKEN_URL, data=form) except httpx.HTTPError as ex: - # 카카오에 못 닿은 것과 토큰이 틀린 것은 다르다 — 로그로 가른다. - LOG.w(f"[kakao-login] 토큰 확인 실패: {type(ex).__name__}") - raise KakaoTokenInvalid("토큰 확인 실패") from ex - if info.status_code != 200: - raise KakaoTokenInvalid(f"토큰이 유효하지 않다({info.status_code})") + LOG.w(f"[kakao-login] 토큰 교환 실패: {type(ex).__name__}") + raise KakaoTokenInvalid("토큰 교환 실패") from ex - data = info.json() - # ★ 구글의 aud 검사에 해당한다. 이게 없으면 남의 앱 토큰으로 우리 계정이 된다. - if str(data.get("app_id") or "") != str(kakao_login_config.app_id): - LOG.w("[kakao-login] 다른 앱에 발급된 토큰 — 거절") - raise KakaoTokenInvalid("우리 앱의 토큰이 아니다") - - uid = str(data.get("id") or "") - if not uid: - raise KakaoTokenInvalid("회원번호가 없다") - - # 이름·이메일은 동의 항목이라 못 받을 수 있다. 못 받아도 로그인은 되어야 한다 — - # 판정 키는 회원번호뿐이고 나머지는 화면에 보여 줄 값일 뿐이다. - email = name = "" + if res.status_code != 200: + # ★ 응답 본문에 code·시크릿이 실려 있을 수 있어 그대로 남기지 않는다. 카카오가 주는 + # error 코드만 남긴다 — 원인 대부분이 KOE006(리다이렉트 불일치)·코드 재사용이다. try: - me = await client.get(_USER_ME_URL, headers=headers) - if me.status_code == 200: - body = me.json() - account = body.get("kakao_account") or {} - email = (account.get("email") or "") if account.get("is_email_valid") is not False else "" - name = ((account.get("profile") or {}).get("nickname") - or (body.get("properties") or {}).get("nickname") or "") - except httpx.HTTPError as ex: - LOG.w(f"[kakao-login] 프로필 조회 실패(로그인은 계속): {type(ex).__name__}") + reason = (res.json() or {}).get("error") or res.status_code + except ValueError: + reason = res.status_code + LOG.w(f"[kakao-login] 토큰 교환 거절: {reason}") + raise KakaoTokenInvalid(f"인가 코드로 토큰을 받지 못했다({reason})") + token = (res.json() or {}).get("access_token") or "" + if not token: + raise KakaoTokenInvalid("액세스 토큰이 없다") + return token + + +async def fetch_account(access_token: str, *, client: httpx.AsyncClient | None = None) -> KakaoAccount: + """액세스 토큰 → 신원. 회원번호만 필수고 나머지는 못 받아도 로그인은 된다.""" + async with _session(client) as http: + try: + res = await http.get(_USER_ME_URL, headers={"Authorization": f"Bearer {access_token}"}) + except httpx.HTTPError as ex: + LOG.w(f"[kakao-login] 사용자 조회 실패: {type(ex).__name__}") + raise KakaoTokenInvalid("사용자 조회 실패") from ex + + if res.status_code != 200: + raise KakaoTokenInvalid(f"사용자 조회 거절({res.status_code})") + + body = res.json() or {} + uid = str(body.get("id") or "") + if not uid: + raise KakaoTokenInvalid("회원번호가 없다") + + # 이름·이메일은 동의 항목이다. 못 받아도 계정은 만들어야 한다 — 판정 키는 회원번호뿐이고 + # 나머지는 화면에 보여 줄 값일 뿐이다. + account = body.get("kakao_account") or {} + # ★ 확인되지 않은 이메일은 신원으로 쓰지 않는다 — 남의 주소를 적어 둔 계정일 수 있고, + # `auth_service` 가 이 값으로 **다른 수단 가입과의 충돌**을 판정한다. 구글에서 + # `email_verified` 를 요구하는 그 자리다. 카카오는 두 칸으로 나눠 준다(사용 가능·인증됨). + usable = account.get("is_email_valid") is not False and account.get("is_email_verified") is not False + email = (account.get("email") or "") if usable else "" + name = ((account.get("profile") or {}).get("nickname") + or (body.get("properties") or {}).get("nickname") or "") return KakaoAccount(uid=uid, email=email, name=name) + + +async def verify_code(code: str, redirect_uri: str = "", *, + client: httpx.AsyncClient | None = None) -> KakaoAccount: + """프론트가 받은 인가 코드 하나로 신원까지. 호출측은 이 함수만 안다.""" + async with _session(client) as http: + return await fetch_account(await exchange_code(code, redirect_uri, client=http), client=http) diff --git a/solution/backend/tests/test_kakao_identity.py b/solution/backend/tests/test_kakao_identity.py new file mode 100644 index 0000000..3440b04 --- /dev/null +++ b/solution/backend/tests/test_kakao_identity.py @@ -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" diff --git a/solution/frontend/src/api/generated/agent/agent.ts b/solution/frontend/src/api/generated/agent/agent.ts new file mode 100644 index 0000000..48df818 --- /dev/null +++ b/solution/frontend/src/api/generated/agent/agent.ts @@ -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 unknown> = Parameters[1]; + +/** + * 연결 상태. + * @summary Link State + */ +export const linkState = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/agent/kakao/link`, method: "GET", signal }, + options, + ); +}; + +export const getLinkStateQueryKey = () => { + return [`/v1/agent/kakao/link`] as const; +}; + +export const getLinkStateQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getLinkStateQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => linkState(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type LinkStateQueryResult = NonNullable< + Awaited> +>; +export type LinkStateQueryError = unknown; + +export function useLinkState< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useLinkState< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useLinkState< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Link State + */ + +export function useLinkState< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getLinkStateQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * 일회용 코드를 낸다. + * @summary Issue Code + */ +export const issueCode = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/agent/kakao/link/code`, method: "POST", signal }, + options, + ); +}; + +export const getIssueCodeMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + void + > = () => { + return issueCode(requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type IssueCodeMutationResult = NonNullable< + Awaited> +>; + +export type IssueCodeMutationError = unknown; + +/** + * @summary Issue Code + */ +export const useIssueCode = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + const mutationOptions = getIssueCodeMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * @summary Disconnect + */ +export const disconnect = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/agent/kakao/link/disconnect`, method: "POST", signal }, + options, + ); +}; + +export const getDisconnectMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + void + > = () => { + return disconnect(requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DisconnectMutationResult = NonNullable< + Awaited> +>; + +export type DisconnectMutationError = unknown; + +/** + * @summary Disconnect + */ +export const useDisconnect = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + const mutationOptions = getDisconnectMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * 대화창을 열 수 있는지. + * @summary Status + */ +export const status = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/agent/status`, method: "GET", signal }, + options, + ); +}; + +export const getStatusQueryKey = () => { + return [`/v1/agent/status`] as const; +}; + +export const getStatusQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getStatusQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => status(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type StatusQueryResult = NonNullable>>; +export type StatusQueryError = unknown; + +export function useStatus< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useStatus< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useStatus< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Status + */ + +export function useStatus< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getStatusQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary Chat + */ +export const chat = ( + placeId: string, + reqChat: ReqChat, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { + 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>, + TError, + { placeId: string; data: ReqChat }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { placeId: string; data: ReqChat } + > = (props) => { + const { placeId, data } = props ?? {}; + + return chat(placeId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ChatMutationResult = NonNullable>>; +export type ChatMutationBody = ReqChat; +export type ChatMutationError = HTTPValidationError; + +/** + * @summary Chat + */ +export const useChat = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string; data: ReqChat }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { placeId: string; data: ReqChat }, + TContext +> => { + const mutationOptions = getChatMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * 헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다. + * @summary Webhook + */ +export const webhook = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/agent/kakao/webhook`, method: "POST", signal }, + options, + ); +}; + +export const getWebhookMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + void + > = () => { + return webhook(requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type WebhookMutationResult = NonNullable< + Awaited> +>; + +export type WebhookMutationError = HTTPValidationError; + +/** + * @summary Webhook + */ +export const useWebhook = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + const mutationOptions = getWebhookMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * 헤더를 못 넣는 경우의 대안. + +★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다. + * @summary Webhook With Path Secret + */ +export const webhookWithPathSecret = ( + secret: string, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/agent/kakao/webhook/${secret}`, method: "POST", signal }, + options, + ); +}; + +export const getWebhookWithPathSecretMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { secret: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { secret: string } + > = (props) => { + const { secret } = props ?? {}; + + return webhookWithPathSecret(secret, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type WebhookWithPathSecretMutationResult = NonNullable< + Awaited> +>; + +export type WebhookWithPathSecretMutationError = HTTPValidationError; + +/** + * @summary Webhook With Path Secret + */ +export const useWebhookWithPathSecret = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { secret: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { secret: string }, + TContext +> => { + const mutationOptions = getWebhookWithPathSecretMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/api/generated/auth/auth.ts b/solution/frontend/src/api/generated/auth/auth.ts index abfa289..d0fae27 100644 --- a/solution/frontend/src/api/generated/auth/auth.ts +++ b/solution/frontend/src/api/generated/auth/auth.ts @@ -23,6 +23,7 @@ import type { import type { HTTPValidationError, ReqGoogleLogin, + ReqKakaoLogin, ReqLogin, ReqSignup, ReqUpdateMe, @@ -311,6 +312,98 @@ export const useGoogleLogin = < return useMutation(mutationOptions, queryClient); }; +/** + * 카카오 **인가 코드**를 서버가 토큰으로 교환해 신원을 확인하고 JWT 를 발급한다. 처음 온 계정은 그 자리에서 만든다. ★ 코드는 1회용이라 같은 값으로 두 번 부르면 실패한다. ★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, 그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md). + * @summary 카카오 로그인 + */ +export const kakaoLogin = ( + reqKakaoLogin: ReqKakaoLogin, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { + 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>, + TError, + { data: ReqKakaoLogin }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { data: ReqKakaoLogin } + > = (props) => { + const { data } = props ?? {}; + + return kakaoLogin(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type KakaoLoginMutationResult = NonNullable< + Awaited> +>; +export type KakaoLoginMutationBody = ReqKakaoLogin; +export type KakaoLoginMutationError = void | HTTPValidationError; + +/** + * @summary 카카오 로그인 + */ +export const useKakaoLogin = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ReqKakaoLogin }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ReqKakaoLogin }, + TContext +> => { + const mutationOptions = getKakaoLoginMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; /** * refresh 토큰으로 access 토큰을 재발급한다. * @summary 액세스 토큰 갱신 diff --git a/solution/frontend/src/api/generated/default/default.ts b/solution/frontend/src/api/generated/default/default.ts index cbbd194..18ea2f1 100644 --- a/solution/frontend/src/api/generated/default/default.ts +++ b/solution/frontend/src/api/generated/default/default.ts @@ -176,14 +176,7 @@ export function useHealthzHealthzGet< } /** - * ★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), -이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다). - -★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도 - 같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를 - 주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가 - 2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams - webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다. + * healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다). * @summary Readyz */ export const readyzReadyzGet = ( diff --git a/solution/frontend/src/api/generated/image/image.ts b/solution/frontend/src/api/generated/image/image.ts index fc48663..c3f4bda 100644 --- a/solution/frontend/src/api/generated/image/image.ts +++ b/solution/frontend/src/api/generated/image/image.ts @@ -24,8 +24,7 @@ import { customFetch } from "../../mutator/custom-fetch"; type SecondParameter unknown> = Parameters[1]; /** - * 인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는 -것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다). + * 인증을 요구하지 않는다. * @summary 사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다 */ export const relay = ( diff --git a/solution/frontend/src/api/generated/local-content/local-content.ts b/solution/frontend/src/api/generated/local-content/local-content.ts index 0ed973c..e6ae01b 100644 --- a/solution/frontend/src/api/generated/local-content/local-content.ts +++ b/solution/frontend/src/api/generated/local-content/local-content.ts @@ -208,7 +208,7 @@ export function useListPlaceContents< } /** - * 빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다. + * 빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. * @summary 업장 주변정보 재수집 (TourAPI 반경) */ export const syncPlace = ( @@ -295,7 +295,7 @@ export const useSyncPlace = ( return useMutation(mutationOptions, queryClient); }; /** - * 숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다. + * 숨긴 항목은 재수집이 되살리지 않는다. * @summary 주변정보 숨김/해제 */ export const hidePlaceContent = ( diff --git a/solution/frontend/src/api/generated/media/media.ts b/solution/frontend/src/api/generated/media/media.ts index 87e5077..fb3d872 100644 --- a/solution/frontend/src/api/generated/media/media.ts +++ b/solution/frontend/src/api/generated/media/media.ts @@ -4,15 +4,18 @@ * Web4Ai API * OpenAPI spec version: 0.1.0 */ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, + MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, UseQueryOptions, UseQueryResult, } from "@tanstack/react-query"; @@ -185,3 +188,186 @@ export function useListMedia< return query; } + +/** + * 그 사진을 REJECTED 로 내려 발행본에서 뺀다. ★ 지우지 않는다 — origin_url·source_type 이 남아 있어야 재게시 권리(docs/DECISIONS.md 1-2) 결론이 났을 때 되짚을 수 있고, 잘못 내렸을 때 되돌릴 수도 있다. 응답은 갱신된 목록이다. + * @summary 사진 내리기 + */ +export const hideMedia = ( + placeId: string, + mediaId: string, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { + url: `/v1/place/${placeId}/media/${mediaId}/hide`, + method: "POST", + signal, + }, + options, + ); +}; + +export const getHideMediaMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string; mediaId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { placeId: string; mediaId: string } + > = (props) => { + const { placeId, mediaId } = props ?? {}; + + return hideMedia(placeId, mediaId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type HideMediaMutationResult = NonNullable< + Awaited> +>; + +export type HideMediaMutationError = void | HTTPValidationError; + +/** + * @summary 사진 내리기 + */ +export const useHideMedia = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string; mediaId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + 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, + signal?: AbortSignal, +) => { + return customFetch( + { + url: `/v1/place/${placeId}/media/${mediaId}/primary`, + method: "POST", + signal, + }, + options, + ); +}; + +export const getSetPrimaryMediaMutationOptions = < + TError = void | HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string; mediaId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { placeId: string; mediaId: string } + > = (props) => { + const { placeId, mediaId } = props ?? {}; + + return setPrimaryMedia(placeId, mediaId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SetPrimaryMediaMutationResult = NonNullable< + Awaited> +>; + +export type SetPrimaryMediaMutationError = void | HTTPValidationError; + +/** + * @summary 대표 사진 지정 + */ +export const useSetPrimaryMedia = < + TError = void | HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { placeId: string; mediaId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { placeId: string; mediaId: string }, + TContext +> => { + const mutationOptions = getSetPrimaryMediaMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/solution/frontend/src/api/generated/model/authProvider.ts b/solution/frontend/src/api/generated/model/authProvider.ts index 5f73a5b..962d564 100644 --- a/solution/frontend/src/api/generated/model/authProvider.ts +++ b/solution/frontend/src/api/generated/model/authProvider.ts @@ -6,11 +6,7 @@ */ /** - * users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가. - -한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다. -이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면 -남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절. + * users.provider 코드값. */ export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider]; @@ -18,4 +14,5 @@ export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider]; export const AuthProvider = { LOCAL: 1, GOOGLE: 2, + KAKAO: 3, } as const; diff --git a/solution/frontend/src/api/generated/model/buildStatus.ts b/solution/frontend/src/api/generated/model/buildStatus.ts index 1d296cb..d7f131d 100644 --- a/solution/frontend/src/api/generated/model/buildStatus.ts +++ b/solution/frontend/src/api/generated/model/buildStatus.ts @@ -6,7 +6,7 @@ */ /** - * site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다. + * site_versions.build_status 코드값. */ export type BuildStatus = (typeof BuildStatus)[keyof typeof BuildStatus]; diff --git a/solution/frontend/src/api/generated/model/confirm.ts b/solution/frontend/src/api/generated/model/confirm.ts new file mode 100644 index 0000000..b9e63f0 --- /dev/null +++ b/solution/frontend/src/api/generated/model/confirm.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/confirmArgs.ts b/solution/frontend/src/api/generated/model/confirmArgs.ts new file mode 100644 index 0000000..f129f15 --- /dev/null +++ b/solution/frontend/src/api/generated/model/confirmArgs.ts @@ -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 }; diff --git a/solution/frontend/src/api/generated/model/editRedirectParams.ts b/solution/frontend/src/api/generated/model/editRedirectParams.ts new file mode 100644 index 0000000..fb110d7 --- /dev/null +++ b/solution/frontend/src/api/generated/model/editRedirectParams.ts @@ -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; +}; diff --git a/solution/frontend/src/api/generated/model/errorInfo.ts b/solution/frontend/src/api/generated/model/errorInfo.ts index ae2ca0a..0e1b31f 100644 --- a/solution/frontend/src/api/generated/model/errorInfo.ts +++ b/solution/frontend/src/api/generated/model/errorInfo.ts @@ -9,7 +9,7 @@ import type { ErrorInfoCode } from "./errorInfoCode"; import type { ErrorInfoDesc } from "./errorInfoDesc"; /** - * 모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다. + * 모든 응답에 공통으로 실리는 결과 정보. */ export interface ErrorInfo { success?: ErrorInfoSuccess; diff --git a/solution/frontend/src/api/generated/model/externalPlaceSource.ts b/solution/frontend/src/api/generated/model/externalPlaceSource.ts index bbf6c1a..e425d8a 100644 --- a/solution/frontend/src/api/generated/model/externalPlaceSource.ts +++ b/solution/frontend/src/api/generated/model/externalPlaceSource.ts @@ -6,10 +6,7 @@ */ /** - * places.external_source 코드값. 동일 업소 검증에 쓴 외부 장소 DB. - -카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다. -네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다. + * places.external_source 코드값. */ export type ExternalPlaceSource = (typeof ExternalPlaceSource)[keyof typeof ExternalPlaceSource]; diff --git a/solution/frontend/src/api/generated/model/factStatus.ts b/solution/frontend/src/api/generated/model/factStatus.ts index 9e9d937..63000fa 100644 --- a/solution/frontend/src/api/generated/model/factStatus.ts +++ b/solution/frontend/src/api/generated/model/factStatus.ts @@ -7,7 +7,6 @@ /** * facts.status / faqs.status / routes.status 공용 검증 상태. -★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES). */ export type FactStatus = (typeof FactStatus)[keyof typeof FactStatus]; diff --git a/solution/frontend/src/api/generated/model/factWriteOutcome.ts b/solution/frontend/src/api/generated/model/factWriteOutcome.ts index 2a19d1a..c56d745 100644 --- a/solution/frontend/src/api/generated/model/factWriteOutcome.ts +++ b/solution/frontend/src/api/generated/model/factWriteOutcome.ts @@ -6,8 +6,7 @@ */ /** - * fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 — -특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다. + * fact 기록 결과. */ export type FactWriteOutcome = (typeof FactWriteOutcome)[keyof typeof FactWriteOutcome]; diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index a0f6ce3..0e9c746 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -12,7 +12,10 @@ export * from "./auditCheckDataRecommendation"; export * from "./authProvider"; export * from "./buildStatus"; export * from "./checkSlugParams"; +export * from "./confirm"; +export * from "./confirmArgs"; export * from "./decision"; +export * from "./editRedirectParams"; export * from "./errorInfo"; export * from "./errorInfoCode"; export * from "./errorInfoDesc"; @@ -184,6 +187,8 @@ export * from "./reqBookingRequestEmail"; export * from "./reqBookingRequestGuests"; export * from "./reqBookingRequestMessage"; export * from "./reqBookingRequestStay"; +export * from "./reqChat"; +export * from "./reqChatConfirm"; export * from "./reqCreateFaq"; export * from "./reqCreateFaqSortOrder"; export * from "./reqCreateLink"; @@ -194,6 +199,7 @@ export * from "./reqEditPost"; export * from "./reqExtractFacts"; export * from "./reqGoogleLogin"; export * from "./reqHidePlaceContent"; +export * from "./reqKakaoLogin"; export * from "./reqLogin"; export * from "./reqPublishLocalContent"; export * from "./reqReview"; diff --git a/solution/frontend/src/api/generated/model/jobStatus.ts b/solution/frontend/src/api/generated/model/jobStatus.ts index 0a1b1a3..cde1fb4 100644 --- a/solution/frontend/src/api/generated/model/jobStatus.ts +++ b/solution/frontend/src/api/generated/model/jobStatus.ts @@ -6,10 +6,7 @@ */ /** - * jobs.status 코드값. 작업 큐 상태. - -전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면 -PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter). + * jobs.status 코드값. */ export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus]; diff --git a/solution/frontend/src/api/generated/model/jobType.ts b/solution/frontend/src/api/generated/model/jobType.ts index 0fee41a..c9ae9a3 100644 --- a/solution/frontend/src/api/generated/model/jobType.ts +++ b/solution/frontend/src/api/generated/model/jobType.ts @@ -6,10 +6,7 @@ */ /** - * jobs.job_type 코드값. 수집·비전·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다. - -무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 — -크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다. + * jobs.job_type 코드값. */ export type JobType = (typeof JobType)[keyof typeof JobType]; diff --git a/solution/frontend/src/api/generated/model/linkChannel.ts b/solution/frontend/src/api/generated/model/linkChannel.ts index 9587b58..882313a 100644 --- a/solution/frontend/src/api/generated/model/linkChannel.ts +++ b/solution/frontend/src/api/generated/model/linkChannel.ts @@ -6,7 +6,7 @@ */ /** - * place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류. + * place_channels.channel 코드값. */ export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel]; diff --git a/solution/frontend/src/api/generated/model/localContentType.ts b/solution/frontend/src/api/generated/model/localContentType.ts index 753d981..7138ad9 100644 --- a/solution/frontend/src/api/generated/model/localContentType.ts +++ b/solution/frontend/src/api/generated/model/localContentType.ts @@ -6,7 +6,7 @@ */ /** - * local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류. + * local_contents.content_type 코드값. */ export type LocalContentType = (typeof LocalContentType)[keyof typeof LocalContentType]; diff --git a/solution/frontend/src/api/generated/model/localSource.ts b/solution/frontend/src/api/generated/model/localSource.ts index 99c6133..79bbac1 100644 --- a/solution/frontend/src/api/generated/model/localSource.ts +++ b/solution/frontend/src/api/generated/model/localSource.ts @@ -6,7 +6,7 @@ */ /** - * local_contents.source 코드값. 어느 외부 API 에서 왔는지. + * local_contents.source 코드값. */ export type LocalSource = (typeof LocalSource)[keyof typeof LocalSource]; diff --git a/solution/frontend/src/api/generated/model/mediaData.ts b/solution/frontend/src/api/generated/model/mediaData.ts index 3d56d07..134d41d 100644 --- a/solution/frontend/src/api/generated/model/mediaData.ts +++ b/solution/frontend/src/api/generated/model/mediaData.ts @@ -18,11 +18,6 @@ import type { MediaDataCreatedAt } from "./mediaDataCreatedAt"; /** * 사진 1건. - -★ source_type 과 origin_url 을 반드시 함께 내려보낸다 — 크롤링 이미지의 재게시 권리가 - 아직 미결이라(docs/DECISIONS.md 1-2), 결론이 '불가'로 나면 발행에서 source_type = CRAWL 을 - 통째로 제외해야 한다. 화면이 출처를 모르면 무엇이 빠질지도 미리 보여줄 수 없다. - origin_url 은 그때 '이 사진은 어디서 왔는가'를 증명하는 유일한 근거다. */ export interface MediaData { media_id: string; diff --git a/solution/frontend/src/api/generated/model/mediaStatus.ts b/solution/frontend/src/api/generated/model/mediaStatus.ts index 0b997c5..8e1f883 100644 --- a/solution/frontend/src/api/generated/model/mediaStatus.ts +++ b/solution/frontend/src/api/generated/model/mediaStatus.ts @@ -6,7 +6,7 @@ */ /** - * media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다. + * media.status 코드값. */ export type MediaStatus = (typeof MediaStatus)[keyof typeof MediaStatus]; diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts index 407fefe..5e370b7 100644 --- a/solution/frontend/src/api/generated/model/mySiteData.ts +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -17,9 +17,6 @@ import type { MySiteDataThumbnailUrl } from "./mySiteDataThumbnailUrl"; /** * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). - -★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유). -★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다. */ export interface MySiteData { place_id: string; diff --git a/solution/frontend/src/api/generated/model/opsSiteData.ts b/solution/frontend/src/api/generated/model/opsSiteData.ts index b1abc59..17f1ca7 100644 --- a/solution/frontend/src/api/generated/model/opsSiteData.ts +++ b/solution/frontend/src/api/generated/model/opsSiteData.ts @@ -17,8 +17,6 @@ import type { OpsSiteDataOwnerName } from "./opsSiteDataOwnerName"; /** * 전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자. - -★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다. */ export interface OpsSiteData { place_id: string; diff --git a/solution/frontend/src/api/generated/model/opsUserData.ts b/solution/frontend/src/api/generated/model/opsUserData.ts index fd490c8..01bddf3 100644 --- a/solution/frontend/src/api/generated/model/opsUserData.ts +++ b/solution/frontend/src/api/generated/model/opsUserData.ts @@ -14,7 +14,7 @@ import type { OpsUserDataCreatedAt } from "./opsUserDataCreatedAt"; import type { OpsUserDataLastAccessedAt } from "./opsUserDataLastAccessedAt"; /** - * 전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다. + * 전 계정 목록의 한 줄. */ export interface OpsUserData { user_id: string; diff --git a/solution/frontend/src/api/generated/model/placeCandidate.ts b/solution/frontend/src/api/generated/model/placeCandidate.ts index d48b187..c0f4120 100644 --- a/solution/frontend/src/api/generated/model/placeCandidate.ts +++ b/solution/frontend/src/api/generated/model/placeCandidate.ts @@ -15,7 +15,7 @@ import type { PlaceCandidatePlaceUrl } from "./placeCandidatePlaceUrl"; import type { PlaceCandidateNaverPlaceUrl } from "./placeCandidateNaverPlaceUrl"; /** - * 외부 장소 DB 에서 찾은 후보 1건. UI 가 이걸 카드로 그려 사람이 고른다. + * 외부 장소 DB 에서 찾은 후보 1건. */ export interface PlaceCandidate { external_place_id?: PlaceCandidateExternalPlaceId; diff --git a/solution/frontend/src/api/generated/model/placeCategory.ts b/solution/frontend/src/api/generated/model/placeCategory.ts index 27a66b8..b37ce62 100644 --- a/solution/frontend/src/api/generated/model/placeCategory.ts +++ b/solution/frontend/src/api/generated/model/placeCategory.ts @@ -6,8 +6,7 @@ */ /** - * places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1. -업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가. + * places.category 코드값. */ export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory]; diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts index 5007240..ee192af 100644 --- a/solution/frontend/src/api/generated/model/placeSearchItem.ts +++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts @@ -11,11 +11,6 @@ import type { PlaceSearchItemNaverPlaceUrl } from "./placeSearchItemNaverPlaceUr /** * 공개 검색 결과 1건. - -★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은 - 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. -★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, - 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다. */ export interface PlaceSearchItem { name?: string; diff --git a/solution/frontend/src/api/generated/model/placeStatus.ts b/solution/frontend/src/api/generated/model/placeStatus.ts index 960273d..f283102 100644 --- a/solution/frontend/src/api/generated/model/placeStatus.ts +++ b/solution/frontend/src/api/generated/model/placeStatus.ts @@ -6,8 +6,7 @@ */ /** - * places.status 코드값. 사업장 생애주기. -해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다). + * places.status 코드값. */ export type PlaceStatus = (typeof PlaceStatus)[keyof typeof PlaceStatus]; diff --git a/solution/frontend/src/api/generated/model/publishRejectReason.ts b/solution/frontend/src/api/generated/model/publishRejectReason.ts index cbd0992..1ad0c39 100644 --- a/solution/frontend/src/api/generated/model/publishRejectReason.ts +++ b/solution/frontend/src/api/generated/model/publishRejectReason.ts @@ -6,7 +6,7 @@ */ /** - * publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3). + * publish_logs.reject_reason 코드값. */ export type PublishRejectReason = (typeof PublishRejectReason)[keyof typeof PublishRejectReason]; diff --git a/solution/frontend/src/api/generated/model/renderStatusData.ts b/solution/frontend/src/api/generated/model/renderStatusData.ts index 4f20757..48433fe 100644 --- a/solution/frontend/src/api/generated/model/renderStatusData.ts +++ b/solution/frontend/src/api/generated/model/renderStatusData.ts @@ -9,10 +9,7 @@ import type { RenderStatusDataRenderedVersion } from "./renderStatusDataRendered import type { RenderStatusDataError } from "./renderStatusDataError"; /** - * 정적 페이지가 실제로 구워졌는지. 프리렌더가 남긴 보고서를 그대로 옮긴다. - -★ 발행 기록(DB)과 실제 페이지(파일)는 다른 곳에 산다. 이게 없으면 프리렌더가 깨져도 - DB 는 "발행됨"이라 말하고 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다. + * 정적 페이지가 실제로 구워졌는지. */ export interface RenderStatusData { state?: string; diff --git a/solution/frontend/src/api/generated/model/reqChat.ts b/solution/frontend/src/api/generated/model/reqChat.ts new file mode 100644 index 0000000..81a3805 --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqChat.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/reqChatConfirm.ts b/solution/frontend/src/api/generated/model/reqChatConfirm.ts new file mode 100644 index 0000000..f98782b --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqChatConfirm.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/reqCreateFaq.ts b/solution/frontend/src/api/generated/model/reqCreateFaq.ts index 45f96b8..15b8627 100644 --- a/solution/frontend/src/api/generated/model/reqCreateFaq.ts +++ b/solution/frontend/src/api/generated/model/reqCreateFaq.ts @@ -8,9 +8,6 @@ import type { ReqCreateFaqSortOrder } from "./reqCreateFaqSortOrder"; /** * 사장님이 직접 쓴 FAQ. - -★ generated_by 를 요청으로 받지 않는다 — 받으면 LLM 생성물을 사람이 쓴 것처럼 올려 - 승인 절차를 통째로 건너뛸 수 있다. 출처는 서버가 OWNER 로 고정한다. */ export interface ReqCreateFaq { question?: string; diff --git a/solution/frontend/src/api/generated/model/reqExtractFacts.ts b/solution/frontend/src/api/generated/model/reqExtractFacts.ts index 97a4448..49e21b4 100644 --- a/solution/frontend/src/api/generated/model/reqExtractFacts.ts +++ b/solution/frontend/src/api/generated/model/reqExtractFacts.ts @@ -7,13 +7,6 @@ /** * 사장님이 붙여넣은 원문에서 fact 를 뽑는다. - -★ 왜 이 입구가 필요한가 (2026-08-31) - TourAPI 에 없고 네이버에도 요금표뿐인 업소가 흔하다(실측: 조이모텔 — 수집 fact 6건이 - 전부 대실·숙박 요금이었다). 그런 업소는 자동 수집만으로는 발행 근거가 영영 안 찬다. - 폴백 3단계의 2번(사장님이 직접 붙여넣기)이 여기다. - -★ 뽑은 값은 전부 **후보(UNVERIFIED)** 로 들어간다. 사장님이 확인해야 사이트에 나간다. */ export interface ReqExtractFacts { text?: string; diff --git a/solution/frontend/src/api/generated/model/reqGoogleLogin.ts b/solution/frontend/src/api/generated/model/reqGoogleLogin.ts index 9e92d39..b7c19da 100644 --- a/solution/frontend/src/api/generated/model/reqGoogleLogin.ts +++ b/solution/frontend/src/api/generated/model/reqGoogleLogin.ts @@ -6,10 +6,7 @@ */ /** - * 구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다. - -필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 — -`access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다. + * 구글 로그인. */ export interface ReqGoogleLogin { credential?: string; diff --git a/solution/frontend/src/api/generated/model/reqKakaoLogin.ts b/solution/frontend/src/api/generated/model/reqKakaoLogin.ts new file mode 100644 index 0000000..4440ccb --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqKakaoLogin.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/reqRollback.ts b/solution/frontend/src/api/generated/model/reqRollback.ts index 1b8f5e4..e3246cb 100644 --- a/solution/frontend/src/api/generated/model/reqRollback.ts +++ b/solution/frontend/src/api/generated/model/reqRollback.ts @@ -6,12 +6,7 @@ */ /** - * 예전 버전으로 공개 주소를 되돌린다. - -★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면 - (보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서 - 돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는 - 그대로 통과한다). + * 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. */ export interface ReqRollback { target_version: number; diff --git a/solution/frontend/src/api/generated/model/reqSignup.ts b/solution/frontend/src/api/generated/model/reqSignup.ts index 7ca2aa0..96a30ed 100644 --- a/solution/frontend/src/api/generated/model/reqSignup.ts +++ b/solution/frontend/src/api/generated/model/reqSignup.ts @@ -7,11 +7,7 @@ import type { ReqSignupName } from "./reqSignupName"; /** - * id/pw 가입. 가입 = 계정 1개다. - -★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 - 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 - **중복 판정용** 이다. + * id/pw 가입. */ export interface ReqSignup { id?: string; diff --git a/solution/frontend/src/api/generated/model/reqSiteSlug.ts b/solution/frontend/src/api/generated/model/reqSiteSlug.ts index e13f161..e9ea838 100644 --- a/solution/frontend/src/api/generated/model/reqSiteSlug.ts +++ b/solution/frontend/src/api/generated/model/reqSiteSlug.ts @@ -7,9 +7,6 @@ /** * 사이트 주소(네임스페이스) 예약. - -★ 서버가 상호명으로 자동 확정하지 않는다 — 사장님이 고른다. - 주소는 AI 검색이 색인하는 영구 식별자라, 한 번 정해지면 되돌리는 비용이 사장님 몫이 된다. */ export interface ReqSiteSlug { slug?: string; diff --git a/solution/frontend/src/api/generated/model/reqSiteStatus.ts b/solution/frontend/src/api/generated/model/reqSiteStatus.ts index 786cb44..ac7ca9a 100644 --- a/solution/frontend/src/api/generated/model/reqSiteStatus.ts +++ b/solution/frontend/src/api/generated/model/reqSiteStatus.ts @@ -7,13 +7,7 @@ import type { PublishAction } from "./publishAction"; /** - * 발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 — -색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다. - -★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도 - (`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가 - '아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15). - 무엇을 할지는 부르는 쪽이 적는다. + * 발행 상태 전이. */ export interface ReqSiteStatus { action: PublishAction; diff --git a/solution/frontend/src/api/generated/model/reqSiteTemplate.ts b/solution/frontend/src/api/generated/model/reqSiteTemplate.ts index af09de5..cabb139 100644 --- a/solution/frontend/src/api/generated/model/reqSiteTemplate.ts +++ b/solution/frontend/src/api/generated/model/reqSiteTemplate.ts @@ -7,10 +7,6 @@ /** * 템플릿 선택 저장. - -★ 서버는 값을 검증하지 않는다. 템플릿 목록은 프론트(배리에이션 레지스트리)가 소유하므로 - 여기서 화이트리스트를 두면 템플릿을 하나 늘릴 때마다 백엔드를 같이 고쳐야 한다. - 잘못된 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨린다 — 화면이 깨지지 않는다. */ export interface ReqSiteTemplate { template_id?: string; diff --git a/solution/frontend/src/api/generated/model/reqSiteTheme.ts b/solution/frontend/src/api/generated/model/reqSiteTheme.ts index ebaee6a..6b514ef 100644 --- a/solution/frontend/src/api/generated/model/reqSiteTheme.ts +++ b/solution/frontend/src/api/generated/model/reqSiteTheme.ts @@ -7,32 +7,7 @@ import type { ReqSiteThemeTheme } from "./reqSiteThemeTheme"; /** - * 디자인(색·서체·섹션) 저장. 에디터 좌측 패널과 [디자인] 탭이 만든 결과 그대로 온다. - -★ Req_SiteTemplate 과 같은 철학이다 — 서버는 값을 해석하지도 검증하지도 않는다. - 섹션 목록도, 배리에이션 키도, 색 토큰 이름도 프론트(배리에이션 레지스트리)가 소유한다. - 여기에 화이트리스트를 두면 프론트에 섹션이나 배리에이션이 하나 늘 때마다 백엔드를 같이 고쳐야 하고, - 그 사이 사장님이 고른 값은 조용히 버려진다. 모르는 값이 들어와도 발행 잡이 업종 기본으로 - 떨어뜨리므로 화면은 깨지지 않는다. - -★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: - {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}} - sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 - (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다. - pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — - 서버는 배달부지 심판이 아니다. - -★ colorPaletteId 는 **에디터 복원 전용**이다. 사장님이 고른 색 프리셋 id 이고, - 발행 렌더러는 이걸 안 쓰고 해석된 colors 만 쓴다. DB 에는 저장하고 응답으로도 그대로 돌려주지만, - 발행 payload 의 theme 에는 싣지 않는다 — 발행 계약(SitePayload.SiteTheme)에 없는 필드다. - -★ templateId 는 이 body 에 없다. sites.template_id 컬럼과 POST /template 이 계속 담당한다 — - 두 곳에 두면 어느 쪽이 진짜인지 갈린다. - -★ 딱 하나 막는 것은 크기다. 해석하지 않는 값을 그대로 보관한다는 건 곧 무엇이든 들어올 수 있다는 - 뜻이라, 상한이 없으면 jsonb 컬럼 하나가 DB 와 스냅샷을 통째로 부풀린다. - 상한(services/site_service._THEME_MAX_BYTES)은 서비스가 직렬화 크기로 잰다 — - 필드 개수로 재면 값 하나가 긴 경우를 못 막는다. + * 색·섹션 저장. */ export interface ReqSiteTheme { theme?: ReqSiteThemeTheme; diff --git a/solution/frontend/src/api/generated/model/reqStartBuild.ts b/solution/frontend/src/api/generated/model/reqStartBuild.ts index d161507..25de26c 100644 --- a/solution/frontend/src/api/generated/model/reqStartBuild.ts +++ b/solution/frontend/src/api/generated/model/reqStartBuild.ts @@ -7,9 +7,6 @@ /** * 정적 빌드 시작. - -publish=true 면 발행 검수 게이트를 통과했을 때 바로 발행까지 한다. -★ 게이트를 통과하지 못하면 발행되지 않는다 — 우회 옵션은 없다. */ export interface ReqStartBuild { publish?: boolean; diff --git a/solution/frontend/src/api/generated/model/reqStartCollect.ts b/solution/frontend/src/api/generated/model/reqStartCollect.ts index 16a6651..b5009ea 100644 --- a/solution/frontend/src/api/generated/model/reqStartCollect.ts +++ b/solution/frontend/src/api/generated/model/reqStartCollect.ts @@ -6,7 +6,7 @@ */ /** - * 수집 시작. 몇 분 걸리므로 동기로 처리하지 않고 잡을 적재한 뒤 즉시 응답한다. + * 수집 시작. */ export interface ReqStartCollect { link_ids?: string[]; diff --git a/solution/frontend/src/api/generated/model/reqStartCopy.ts b/solution/frontend/src/api/generated/model/reqStartCopy.ts index 24219d8..29eee98 100644 --- a/solution/frontend/src/api/generated/model/reqStartCopy.ts +++ b/solution/frontend/src/api/generated/model/reqStartCopy.ts @@ -7,8 +7,6 @@ /** * 소개문·FAQ 생성 시작. - -★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다). */ export interface ReqStartCopy { resume?: boolean; diff --git a/solution/frontend/src/api/generated/model/reqStartVision.ts b/solution/frontend/src/api/generated/model/reqStartVision.ts index 04af070..825a347 100644 --- a/solution/frontend/src/api/generated/model/reqStartVision.ts +++ b/solution/frontend/src/api/generated/model/reqStartVision.ts @@ -6,7 +6,7 @@ */ /** - * 사진 분석 시작. 사진 20~50장이라 몇 분 걸린다 — 잡으로 처리한다. + * 사진 분석 시작. */ export interface ReqStartVision { force?: boolean; diff --git a/solution/frontend/src/api/generated/model/reqTransitionFact.ts b/solution/frontend/src/api/generated/model/reqTransitionFact.ts index c607356..254cf8f 100644 --- a/solution/frontend/src/api/generated/model/reqTransitionFact.ts +++ b/solution/frontend/src/api/generated/model/reqTransitionFact.ts @@ -8,7 +8,7 @@ import type { FactStatus } from "./factStatus"; import type { ReqTransitionFactValue } from "./reqTransitionFactValue"; /** - * 검증 상태 전이. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다. + * 검증 상태 전이. */ export interface ReqTransitionFact { status?: FactStatus; diff --git a/solution/frontend/src/api/generated/model/reqTransitionFaq.ts b/solution/frontend/src/api/generated/model/reqTransitionFaq.ts index 571b384..982155d 100644 --- a/solution/frontend/src/api/generated/model/reqTransitionFaq.ts +++ b/solution/frontend/src/api/generated/model/reqTransitionFaq.ts @@ -9,9 +9,7 @@ import type { ReqTransitionFaqQuestion } from "./reqTransitionFaqQuestion"; import type { ReqTransitionFaqAnswer } from "./reqTransitionFaqAnswer"; /** - * 검증 상태 전이. 허용 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)가 유일한 소스다. - -CORRECTED 로 갈 때는 고친 question / answer 중 하나 이상이 필요하다(다른 전이에선 무시). + * 검증 상태 전이. */ export interface ReqTransitionFaq { status?: FactStatus; diff --git a/solution/frontend/src/api/generated/model/reqUpsertFact.ts b/solution/frontend/src/api/generated/model/reqUpsertFact.ts index 057576f..c44ab83 100644 --- a/solution/frontend/src/api/generated/model/reqUpsertFact.ts +++ b/solution/frontend/src/api/generated/model/reqUpsertFact.ts @@ -11,9 +11,7 @@ import type { ReqUpsertFactSourceUrl } from "./reqUpsertFactSourceUrl"; import type { ReqUpsertFactExpiresAt } from "./reqUpsertFactExpiresAt"; /** - * fact 기록. key 는 사업장 업종의 스키마에 있는 것만 허용한다. - -★ source_type 이 owner 가 아니면 source_url 이 필수다 — 출처 없는 사실은 받지 않는다. + * fact 기록. */ export interface ReqUpsertFact { key?: string; diff --git a/solution/frontend/src/api/generated/model/reqVerifyPlace.ts b/solution/frontend/src/api/generated/model/reqVerifyPlace.ts index bee8de0..ce3ad8c 100644 --- a/solution/frontend/src/api/generated/model/reqVerifyPlace.ts +++ b/solution/frontend/src/api/generated/model/reqVerifyPlace.ts @@ -16,10 +16,6 @@ import type { ReqVerifyPlaceCategoryName } from "./reqVerifyPlaceCategoryName"; /** * 카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정). - -★ 이 단계를 통과해야 수집이 열린다. - external_place_id 는 소스에 따라 없을 수 있다 — 네이버는 고유 장소 id 를 주지 않는다. - 그 경우 상호명 + 도로명주소가 중복 판정 키가 되므로 road_address 를 반드시 채워야 한다. */ export interface ReqVerifyPlace { source?: ExternalPlaceSource; diff --git a/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts b/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts index 1b7c36a..5652ef5 100644 --- a/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts +++ b/solution/frontend/src/api/generated/model/reqVerifyPlaceByUrl.ts @@ -7,15 +7,6 @@ /** * 네이버 플레이스 URL 하나로 동일 업소를 확정한다. - -★ 왜 이 경로가 필요한가 - 상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울'). - Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다. - 그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장 - 정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다. - - 서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로 - 옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다). */ export interface ReqVerifyPlaceByUrl { url?: string; diff --git a/solution/frontend/src/api/generated/model/resExtractFacts.ts b/solution/frontend/src/api/generated/model/resExtractFacts.ts index 9cdfa1c..132368d 100644 --- a/solution/frontend/src/api/generated/model/resExtractFacts.ts +++ b/solution/frontend/src/api/generated/model/resExtractFacts.ts @@ -10,9 +10,6 @@ import type { ResExtractedFact } from "./resExtractedFact"; /** * 뽑힌 것과 버려진 것을 **둘 다** 돌려준다. - -★ 조용히 버리지 않는다 — 사장님이 "내가 쓴 체크인 시간이 왜 안 들어갔지" 를 - 화면에서 바로 확인할 수 있어야 한다. */ export interface ResExtractFacts { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resGenerateOne.ts b/solution/frontend/src/api/generated/model/resGenerateOne.ts index 8f5bf7d..f759ef0 100644 --- a/solution/frontend/src/api/generated/model/resGenerateOne.ts +++ b/solution/frontend/src/api/generated/model/resGenerateOne.ts @@ -9,9 +9,7 @@ import type { ResGenerateOneMsg } from "./resGenerateOneMsg"; import type { ResGenerateOnePost } from "./resGenerateOnePost"; /** - * 개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때(2026-09-17, 사장님 -지시: "개별적으로 새로 만들수있게 해줘"). 실패하면 post 가 없다(그 날짜가 이미 찼거나 -소재가 바닥났다). + * 개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때. */ export interface ResGenerateOne { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resJob.ts b/solution/frontend/src/api/generated/model/resJob.ts index b7b0434..7bd5688 100644 --- a/solution/frontend/src/api/generated/model/resJob.ts +++ b/solution/frontend/src/api/generated/model/resJob.ts @@ -9,7 +9,7 @@ import type { ResJobMsg } from "./resJobMsg"; import type { ResJobJob } from "./resJobJob"; /** - * 잡 상태 폴링 응답. 수집·빌드는 몇 분 걸리므로 클라이언트가 이 엔드포인트를 폴링한다. + * 잡 상태 폴링 응답. */ export interface ResJob { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resLocalGuide.ts b/solution/frontend/src/api/generated/model/resLocalGuide.ts index 53bfbd3..77617c3 100644 --- a/solution/frontend/src/api/generated/model/resLocalGuide.ts +++ b/solution/frontend/src/api/generated/model/resLocalGuide.ts @@ -14,8 +14,7 @@ import type { ResLocalGuideItinerariesItem } from "./resLocalGuideItinerariesIte import type { ResLocalGuideSyncedAt } from "./resLocalGuideSyncedAt"; /** - * 에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다 -(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다. + * 에디터 캔버스가 그리는 지역 가이드. */ export interface ResLocalGuide { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resPlaceSearch.ts b/solution/frontend/src/api/generated/model/resPlaceSearch.ts index 0481b69..53e67a7 100644 --- a/solution/frontend/src/api/generated/model/resPlaceSearch.ts +++ b/solution/frontend/src/api/generated/model/resPlaceSearch.ts @@ -11,9 +11,6 @@ import type { PlaceSearchItem } from "./placeSearchItem"; /** * 상호명 공개 검색 결과. - -★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 — - 만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다. */ export interface ResPlaceSearch { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSiteSlug.ts b/solution/frontend/src/api/generated/model/resSiteSlug.ts index 45464bc..a4da8a5 100644 --- a/solution/frontend/src/api/generated/model/resSiteSlug.ts +++ b/solution/frontend/src/api/generated/model/resSiteSlug.ts @@ -11,7 +11,7 @@ import type { ResSiteSlugReason } from "./resSiteSlugReason"; import type { ResSiteSlugSuggestion } from "./resSiteSlugSuggestion"; /** - * 주소 저장 결과. 거부됐으면 왜/대안을 check 와 같은 코드로 돌려준다. + * 주소 저장 결과. */ export interface ResSiteSlug { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSlugCheck.ts b/solution/frontend/src/api/generated/model/resSlugCheck.ts index 2b8fd5e..3a957a3 100644 --- a/solution/frontend/src/api/generated/model/resSlugCheck.ts +++ b/solution/frontend/src/api/generated/model/resSlugCheck.ts @@ -10,7 +10,7 @@ import type { ResSlugCheckReason } from "./resSlugCheckReason"; import type { ResSlugCheckSuggestion } from "./resSlugCheckSuggestion"; /** - * 주소 사용 가능 확인. UI 가 타이핑 중에 호출한다. + * 주소 사용 가능 확인. */ export interface ResSlugCheck { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resStartCollect.ts b/solution/frontend/src/api/generated/model/resStartCollect.ts index 099a4cf..0e2ba9c 100644 --- a/solution/frontend/src/api/generated/model/resStartCollect.ts +++ b/solution/frontend/src/api/generated/model/resStartCollect.ts @@ -10,7 +10,7 @@ import type { ResStartCollectJobId } from "./resStartCollectJobId"; import type { ResStartCollectStatus } from "./resStartCollectStatus"; /** - * 수집 잡 적재 결과. 클라이언트는 job_id 로 GET /v1/job/{job_id} 를 폴링한다. + * 수집 잡 적재 결과. */ export interface ResStartCollect { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resSyncPlace.ts b/solution/frontend/src/api/generated/model/resSyncPlace.ts index ed54f62..3983c2f 100644 --- a/solution/frontend/src/api/generated/model/resSyncPlace.ts +++ b/solution/frontend/src/api/generated/model/resSyncPlace.ts @@ -8,7 +8,7 @@ import type { ErrorInfo } from "./errorInfo"; import type { ResSyncPlaceMsg } from "./resSyncPlaceMsg"; /** - * 업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지. + * 업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). */ export interface ResSyncPlace { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/resVerifyCandidates.ts b/solution/frontend/src/api/generated/model/resVerifyCandidates.ts index 4d61d3c..80c4f4c 100644 --- a/solution/frontend/src/api/generated/model/resVerifyCandidates.ts +++ b/solution/frontend/src/api/generated/model/resVerifyCandidates.ts @@ -12,9 +12,6 @@ import type { PlaceCandidate } from "./placeCandidate"; /** * 동일 업소 후보 목록. - -★ 자동 판정을 신뢰하지 않는다. outcome 이 MATCHED 여도 후보를 전부 내려보내 - UI 가 사람에게 확인시킬 수 있게 한다 — 남의 가게가 섞이면 그게 제일 비싼 실수다. */ export interface ResVerifyCandidates { result?: ErrorInfo; diff --git a/solution/frontend/src/api/generated/model/showcaseItem.ts b/solution/frontend/src/api/generated/model/showcaseItem.ts index 7aa84ec..5c9f115 100644 --- a/solution/frontend/src/api/generated/model/showcaseItem.ts +++ b/solution/frontend/src/api/generated/model/showcaseItem.ts @@ -9,11 +9,7 @@ import type { ShowcaseItemRegion } from "./showcaseItemRegion"; import type { ShowcaseItemThumbnailUrl } from "./showcaseItemThumbnailUrl"; /** - * 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** - -★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. - place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 - 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다. + * 랜딩 쇼케이스 카드 한 장. */ export interface ShowcaseItem { name: string; diff --git a/solution/frontend/src/api/generated/model/siteStatus.ts b/solution/frontend/src/api/generated/model/siteStatus.ts index e86124e..9fa53ed 100644 --- a/solution/frontend/src/api/generated/model/siteStatus.ts +++ b/solution/frontend/src/api/generated/model/siteStatus.ts @@ -6,7 +6,7 @@ */ /** - * sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다. + * sites.status 코드값. */ export type SiteStatus = (typeof SiteStatus)[keyof typeof SiteStatus]; diff --git a/solution/frontend/src/api/generated/model/sourceType.ts b/solution/frontend/src/api/generated/model/sourceType.ts index 7d549c9..bf8c018 100644 --- a/solution/frontend/src/api/generated/model/sourceType.ts +++ b/solution/frontend/src/api/generated/model/sourceType.ts @@ -7,7 +7,6 @@ /** * facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. -값이 어디서 왔는지 — 모든 사실은 출처를 갖는다. */ export type SourceType = (typeof SourceType)[keyof typeof SourceType]; diff --git a/solution/frontend/src/api/generated/model/userRole.ts b/solution/frontend/src/api/generated/model/userRole.ts index 83aaea5..fdda2c0 100644 --- a/solution/frontend/src/api/generated/model/userRole.ts +++ b/solution/frontend/src/api/generated/model/userRole.ts @@ -7,8 +7,6 @@ /** * users.role 코드값. -1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정). -개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다. */ export type UserRole = (typeof UserRole)[keyof typeof UserRole]; diff --git a/solution/frontend/src/api/generated/site/site.ts b/solution/frontend/src/api/generated/site/site.ts index 1f94ae5..ae0f07f 100644 --- a/solution/frontend/src/api/generated/site/site.ts +++ b/solution/frontend/src/api/generated/site/site.ts @@ -23,6 +23,7 @@ import type { import type { ApprovePageParams, CheckSlugParams, + EditRedirectParams, GenerateMyPostForDateParams, GenerateMyPostsParams, HTTPValidationError, @@ -613,7 +614,7 @@ export const useSetSlug = < return useMutation(mutationOptions, queryClient); }; /** - * 위저드에서 고른 템플릿을 sites.template_id 에 저장한다(사이트 행이 없으면 만든다). ★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. 길이(100자)만 막는다. ★ 주소와 달리 발행 뒤에도 바꿀 수 있다: 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않는다. 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). + * sites.template_id 에 저장한다(사이트 행이 없으면 만든다). 업종 허용 목록에 없는 id는 거절한다. 이미 발행된 사이트면 재빌드 표시(content_updated_at)를 찍는다. * @summary 템플릿(디자인) 선택 저장 */ export const setTemplate = ( @@ -706,8 +707,8 @@ export const useSetTemplate = < return useMutation(mutationOptions, queryClient); }; /** - * 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId","body","data"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). - * @summary 디자인(색·서체·섹션) 저장 + * sites.theme 에 저장한다(사이트 행이 없으면 만든다). body: {"theme":{"colors","look","colorPaletteId","sections":[{"id","name","enabled","locked","body","data"}]}}. 배열 순서가 곧 섹션 순서다. 크기(64KB)만 막는다. 빈 값({})이면 업종 기본으로 되돌린다. + * @summary 디자인(색·섹션) 저장 */ export const setTheme = ( placeId: string, @@ -772,7 +773,7 @@ export type SetThemeMutationBody = ReqSiteTheme; export type SetThemeMutationError = void | HTTPValidationError; /** - * @summary 디자인(색·서체·섹션) 저장 + * @summary 디자인(색·섹션) 저장 */ export const useSetTheme = < TError = void | HTTPValidationError, @@ -1752,6 +1753,153 @@ export const useSendBookingRequest = < return useMutation(mutationOptions, queryClient); }; +/** + * 메일의 '고쳐서 올리려면'. + +★ 액세스 토큰은 **쿼리가 아니라 프래그먼트**로 넘긴다 — 프래그먼트는 서버 로그와 Referer 에 + 남지 않는다. 예전처럼 쿼리에 실으면 주소가 500자가 되는 것보다, 메일 전달 한 번이 + 자정까지의 권한 양도가 되는 쪽이 더 나빴다(services/post_service.open_editor). + * @summary 수정하기 — 일회용 코드를 세션으로 바꿔 편집 화면으로 보낸다 + */ +export const editRedirect = ( + params: EditRedirectParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { 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>, + TError = HTTPValidationError, +>( + params: EditRedirectParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getEditRedirectQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => editRedirect(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type EditRedirectQueryResult = NonNullable< + Awaited> +>; +export type EditRedirectQueryError = HTTPValidationError; + +export function useEditRedirect< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: EditRedirectParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useEditRedirect< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: EditRedirectParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useEditRedirect< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: EditRedirectParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 수정하기 — 일회용 코드를 세션으로 바꿔 편집 화면으로 보낸다 + */ + +export function useEditRedirect< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: EditRedirectParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getEditRedirectQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + /** * @summary 승인 확정 — 누르는 즉시 게재 큐에 넣는다 */ @@ -3183,8 +3331,7 @@ export const useSubmitReview = < return useMutation(mutationOptions, queryClient); }; /** - * 날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고, -화면은 붙은 뒤 이 주소로 최신을 받아 덮는다. + * 날씨(/v1/local/weather)와 같은 공개 조회다. * @summary 게재된 후기 — 발행본이 붙은 뒤 받아 간다 */ export const listReviews = ( diff --git a/solution/frontend/src/components/auth/KakaoSignInButton.tsx b/solution/frontend/src/components/auth/KakaoSignInButton.tsx new file mode 100644 index 0000000..28d1056 --- /dev/null +++ b/solution/frontend/src/components/auth/KakaoSignInButton.tsx @@ -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 ( + + ); +} diff --git a/solution/frontend/src/features/auth/SignInForm.tsx b/solution/frontend/src/features/auth/SignInForm.tsx index 68bbcb3..74d580f 100644 --- a/solution/frontend/src/features/auth/SignInForm.tsx +++ b/solution/frontend/src/features/auth/SignInForm.tsx @@ -3,10 +3,12 @@ import {useState, type FormEvent, type ReactNode} from 'react'; import {LogIn} from 'lucide-react'; import {googleLogin, login} from '@/api'; import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton'; +import {KakaoSignInButton} from '@/components/auth/KakaoSignInButton'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {notifyApiError} from '@/lib/notify'; import {isGoogleLoginEnabled} from '@/lib/googleIdentity'; +import {isKakaoLoginEnabled} from '@/lib/kakaoIdentity'; import {establishSession} from '@/lib/session'; interface SignInFormProps { @@ -107,14 +109,17 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI {/* 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다. */} - {isGoogleLoginEnabled() && ( + {(isKakaoLoginEnabled() || isGoogleLoginEnabled()) && ( <>
또는
- + {/* ★ 카카오를 위에 둔다 — 카톡 채널로 홈페이지를 관리하려면 이 수단이어야 + 회원번호가 이어진다(docs/AGENT.md). 구글로 들어오면 6자리 코드를 거쳐야 한다. */} + + {isGoogleLoginEnabled() && } )} diff --git a/solution/frontend/src/lib/kakaoIdentity.ts b/solution/frontend/src/lib/kakaoIdentity.ts new file mode 100644 index 0000000..55311ba --- /dev/null +++ b/solution/frontend/src/lib/kakaoIdentity.ts @@ -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}; +} diff --git a/solution/frontend/src/pages/KakaoCallbackPage.tsx b/solution/frontend/src/pages/KakaoCallbackPage.tsx new file mode 100644 index 0000000..472cfdb --- /dev/null +++ b/solution/frontend/src/pages/KakaoCallbackPage.tsx @@ -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 ( +
+ {error ? ( +
+

{error}

+ +
+ ) : ( +

카카오 계정으로 로그인하는 중…

+ )} +
+ ); +} diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts index 5d6a8a2..3a7cac1 100644 --- a/solution/frontend/src/routes.ts +++ b/solution/frontend/src/routes.ts @@ -7,6 +7,8 @@ export default [ route('approve/:postId', 'pages/SocialApprovalPage.tsx'), route('login', 'pages/LoginPage.tsx'), + // 카카오 인가 화면이 돌려보내는 자리. 카카오 콘솔의 Redirect URI 와 **같은 경로**여야 한다. + route('auth/kakao/callback', 'pages/KakaoCallbackPage.tsx'), // 로그인 화면의 [회원가입] 이 여기로 온다. route('signup', 'pages/SignupPage.tsx'), diff --git a/solution/frontend/src/vite-env.d.ts b/solution/frontend/src/vite-env.d.ts index 06376a0..59b5a62 100644 --- a/solution/frontend/src/vite-env.d.ts +++ b/solution/frontend/src/vite-env.d.ts @@ -9,6 +9,10 @@ interface ImportMetaEnv { readonly VITE_AUTO_LOGIN_PW?: string; /** 구글 OAuth 클라이언트 ID. */ readonly VITE_GOOGLE_CLIENT_ID?: string; + /** 카카오 REST API 키. 인가 URL 의 client_id 다 — 시크릿은 프론트에 오지 않는다. */ + readonly VITE_KAKAO_REST_API_KEY?: string; + /** 카카오 Redirect URI. 콘솔 등록값·백엔드 설정값과 셋이 같아야 한다. */ + readonly VITE_KAKAO_REDIRECT_URI?: string; } interface ImportMeta {