diff --git a/.env.example b/.env.example index 2c4aa72..ce32b44 100644 --- a/.env.example +++ b/.env.example @@ -119,6 +119,12 @@ KAKAO_LINK_MAX_ATTEMPTS=5 # ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를 # VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다. # ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site +# 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다). +# ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 +# 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요). +# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. +KAKAO_LOGIN_APP_ID= + GOOGLE_CLIENT_ID= # CORS 허용 오리진. 쉼표로 여럿. diff --git a/docs/AGENT.md b/docs/AGENT.md index c6a2abf..a42f6bb 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -38,6 +38,21 @@ 그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.** +## 카카오 로그인으로 가입했으면 — 코드가 필요 없다 (2026-09-30) + +★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다 + (카카오 공식 문서, 봇에 앱키가 물려 있을 때만 온다). 그래서 카카오로 로그인만 해 두면 + 채널에 말을 거는 순간 누구인지 알 수 있다 — `kakao_link_service.link_by_app_user_id`. + +★ **같은 앱이어야 한다.** 로그인 앱과 봇에 물린 앱이 다르면 회원번호가 달라서 + **로그인은 되는데 매칭만 조용히 안 된다**. 증상이 안 보이는 종류다. + +★ **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 `appUserId` 가 없고, + 봇에 앱키가 안 물린 환경에서는 그 값 자체가 오지 않는다 — 그때 유일한 길이다. + +★ 그 카톡이 **이미 다른 사장님**에게 묶여 있으면 잇지 않는다. 조용히 빼앗으면 앞사람이 + 남의 가게를 보게 된다. + ## 절차 — 사장님은 두 번 누른다 1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]** diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 5c1d976..3d1a41c 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -48,7 +48,7 @@ class ErrorType(Enum): ACCOUNT_NOT_FOUND = auto() ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도 ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) - OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 + OAUTH_NOT_CONFIGURED = auto() # 그 소셜 로그인의 키 미설정 — 그 수단만 꺼진다 OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 ACCOUNT_SESSION_REVOKED = auto() @@ -160,6 +160,7 @@ class AuthProvider(CodeEnum): LOCAL = 1 # id/pw GOOGLE = 2 # 구글 ID 토큰 + KAKAO = 3 # 카카오 액세스 토큰 — ★ 회원번호가 챗봇의 appUserId 와 같아 채널 매칭에 쓰인다 class CompanyStatus(CodeEnum): diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index 02aba1d..5271deb 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -102,6 +102,21 @@ class GoogleOAuthConfig(BaseSettings): client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID") +class KakaoLoginConfig(BaseSettings): + """카카오 로그인. app_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. + + ★★ **챗봇에 물린 앱과 같은 앱이어야 한다.** 회원번호(`id`)가 챗봇 웹훅의 + `user.properties.appUserId` 와 같은 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 + 바로 이어진다(6자리 코드 절차가 필요 없어진다). 앱이 다르면 **로그인은 되는데 매칭만 + 조용히 안 된다** — 증상이 안 보이는 종류라 여기 적어 둔다. + ★ app_id 는 비밀이 아니다(토큰 응답에 실려 온다). 서버가 이 값을 갖는 이유는 숨기려는 게 + 아니라 **발급처 대조** 때문이다 — 구글의 `aud` 검사와 같은 자리다.""" + + model_config = _BASE + + app_id: str = Field("", validation_alias="KAKAO_LOGIN_APP_ID") + + class ExternalApiConfig(BaseSettings): """키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다.""" @@ -160,3 +175,8 @@ def get_external_api_config() -> ExternalApiConfig: @lru_cache def get_google_oauth_config() -> GoogleOAuthConfig: return GoogleOAuthConfig() + + +@lru_cache +def get_kakao_login_config() -> KakaoLoginConfig: + return KakaoLoginConfig() diff --git a/solution/backend/config/server_configs.py b/solution/backend/config/server_configs.py index 44c7f32..f244442 100644 --- a/solution/backend/config/server_configs.py +++ b/solution/backend/config/server_configs.py @@ -4,12 +4,14 @@ from config.config_models import ( APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py) ExternalApiConfig, GoogleOAuthConfig, + KakaoLoginConfig, JwtToken, LogConfig, MainDBConfig, WebServerConfig, get_external_api_config, get_google_oauth_config, + get_kakao_login_config, get_jwt_token_config, get_log_config, get_main_db_config, @@ -22,6 +24,7 @@ main_db_config: MainDBConfig = get_main_db_config() jwt_token_config: JwtToken = get_jwt_token_config() external_api_config: ExternalApiConfig = get_external_api_config() google_oauth_config: GoogleOAuthConfig = get_google_oauth_config() +kakao_login_config: KakaoLoginConfig = get_kakao_login_config() # 부팅은 막지 않는다 — 토큰이 필요 없는 로컬 작업까지 못 하게 되면 곤란하다. if not jwt_token_config.access_key or not jwt_token_config.refresh_key: diff --git a/solution/backend/router/v1/agent/kakao_bot.py b/solution/backend/router/v1/agent/kakao_bot.py index 4d71470..56a714a 100644 --- a/solution/backend/router/v1/agent/kakao_bot.py +++ b/solution/backend/router/v1/agent/kakao_bot.py @@ -101,15 +101,16 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict raise HTTPException(404) -async def _answer(utterance: str, speaker: str, deadline: float) -> dict: +async def _answer(utterance: str, speaker: str, deadline: float, app_user_id: str = '') -> dict: """대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다.""" try: - answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline) + answer = await asyncio.wait_for( + channel.handle(utterance, speaker, app_user_id), timeout=deadline) except asyncio.TimeoutError: LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음") return _reply(_TIMEOUT_TEXT) except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다 - LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}") + LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}: {ex}") return _reply(_ERROR_TEXT) return _reply_from(answer) @@ -158,12 +159,12 @@ async def _approve_click(extra: dict, speaker: str) -> dict: return _reply_from(answer) -async def _push(callback_url: str, utterance: str, speaker: str) -> None: +async def _push(callback_url: str, utterance: str, speaker: str, app_user_id: str = '') -> None: """답을 다 만든 뒤 콜백 주소로 보낸다. ★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.""" - payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC) + payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC, app_user_id) try: async with httpx.AsyncClient(timeout=10.0) as client: res = await client.post(callback_url, json=payload) @@ -187,9 +188,7 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict: # 오면 6자리 코드 절차 없이 사장님을 바로 알아볼 수 있다. # ★ 값 자체는 남기지 않는다 — 사용자 식별자다. 어떤 키가 왔는지와 길이만 본다. props = user.get("properties") or {} - if isinstance(props, dict): - LOG.i(f"[agent/kakao] 발화자 키 — properties={sorted(props)} " - f"appUserId={'있음(%d자)' % len(str(props['appUserId'])) if props.get('appUserId') else '없음'}") + app_user_id = str(props.get("appUserId") or "") if isinstance(props, dict) else "" # ★ [승인] 버튼(action: block)의 extra 는 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다. # 글 ID 값은 남기지 않고 어떤 키가 왔는지만 남긴다. @@ -213,10 +212,10 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict: LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} " f"block={(request.get('block') or {}).get('name')!r}") if callback_url: - tasks.add_task(_push, callback_url, utterance, speaker) + tasks.add_task(_push, callback_url, utterance, speaker, app_user_id) return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}} - return await _answer(utterance, speaker, DEADLINE_SEC) + return await _answer(utterance, speaker, DEADLINE_SEC, app_user_id) @router.post("/webhook") diff --git a/solution/backend/router/v1/auth/account.py b/solution/backend/router/v1/auth/account.py index 9b23b42..751e6f4 100644 --- a/solution/backend/router/v1/auth/account.py +++ b/solution/backend/router/v1/auth/account.py @@ -4,7 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse from services.auth_service import AuthService -from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken +from .protocol import ( + Req_GoogleLogin, Req_KakaoLogin, Req_Login, Req_Signup, Req_UpdateMe, + Res_Login, Res_Me, Res_RefreshToken, +) security = HTTPBearer() @@ -37,6 +40,18 @@ async def google_login(req: Req_GoogleLogin, service: AuthService = Depends()): return RemoveNoneResponse(await service.google_login(req)) +@router.post( + path="/kakao", + response_model=Res_Login, + summary="카카오 로그인", + description="카카오 **액세스 토큰**을 카카오에 되물어 검증하고 JWT 를 발급한다. 처음 온 계정은 그 자리에서 만든다. " + "★ 챗봇에 물린 앱과 같은 앱이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은 값이라, " + "그래야 카톡 채널 발화자와 이 계정이 자동으로 이어진다(docs/AGENT.md).", +) +async def kakao_login(req: Req_KakaoLogin, service: AuthService = Depends()): + return RemoveNoneResponse(await service.kakao_login(req)) + + @router.post( path="/refresh_token", dependencies=[Depends(IsValidRefreshToken)], diff --git a/solution/backend/router/v1/auth/protocol.py b/solution/backend/router/v1/auth/protocol.py index f52afd5..fcba08b 100644 --- a/solution/backend/router/v1/auth/protocol.py +++ b/solution/backend/router/v1/auth/protocol.py @@ -29,6 +29,15 @@ class Req_GoogleLogin(AuthProtocol): credential: str = "" +class Req_KakaoLogin(AuthProtocol): + """카카오 로그인. + + ★ 구글은 ID 토큰(credential)을 우리가 직접 검증하지만, 카카오는 **액세스 토큰**을 + 카카오에 되물어 확인한다 — 그래서 필드 이름이 다르다.""" + + access_token: str = "" + + class Res_Login(Res_WebPacketProtocol): access_token: str = "" refresh_token: str = "" diff --git a/solution/backend/services/agent/channel.py b/solution/backend/services/agent/channel.py index bf6a1ba..50f8c21 100644 --- a/solution/backend/services/agent/channel.py +++ b/solution/backend/services/agent/channel.py @@ -196,7 +196,7 @@ def _other_named(rows: list, current, utterance: str): return None -async def handle(utterance: str, channel_user_key: str) -> dict: +async def handle(utterance: str, channel_user_key: str, app_user_id: str = '') -> dict: """대화 한 턴.""" utterance = (utterance or "").strip() if not utterance: @@ -204,6 +204,14 @@ async def handle(utterance: str, channel_user_key: str) -> dict: row = await _link_row(channel_user_key) + # ★ 카카오 로그인으로 가입한 사장님은 **코드 없이** 이어진다 — appUserId 가 로그인 + # 회원번호와 같은 값이라 그 자리에서 알아볼 수 있다(kakao_link_service 주석). + # ★ 코드 경로를 지우지 않는다: id/pw·구글 가입자에게는 이 값이 없고, 봇에 앱키가 + # 안 물린 환경에서는 아예 오지 않는다 — 그때 유일한 길이다. + if row is None and app_user_id: + if await link_service.link_by_app_user_id(app_user_id, channel_user_key): + row = await _link_row(channel_user_key) + # ── 아직 연결되지 않은 발화자 ───────────────────────────────────────── if row is None: found = CODE_PATTERN.fullmatch(utterance.upper()) diff --git a/solution/backend/services/agent/tools.py b/solution/backend/services/agent/tools.py index fc9e724..16289b9 100644 --- a/solution/backend/services/agent/tools.py +++ b/solution/backend/services/agent/tools.py @@ -20,6 +20,7 @@ from enum import Enum from typing import Awaitable, Callable from common.category_schema.loader import get_schema +from common.template_catalog import industry_of from common.enums import ErrorType, PlaceCategory, SourceType from common.models.gmodel import UserInfo from crud.fact_crud import FactCRUD @@ -280,7 +281,11 @@ async def _sections_of(ctx: ToolContext) -> tuple[list, dict]: _fact, site_service = _services() res = await site_service.get_site(ctx.user, ctx.place_id) theme = dict((res.site.theme if res.site and res.site.theme else {}) or {}) - spec = site_payload._DEFAULT_THEME[PlaceCategory(ctx.place.category).value]["sections"] + # ★ 업종 기본 섹션은 `common/template_catalog` 이 단일 출처다(templates.json). + # 예전 `site_payload._DEFAULT_THEME` 를 보던 자리인데, 템플릿 정의가 한 파일로 모이면서 + # 그 이름이 사라졌다 — 여기만 남아 있어 대화의 섹션 기능이 통째로 죽어 있었다 + # (실측 2026-09-30: AttributeError 를 웹훅이 삼켜 "지금은 처리할 수 없어요" 로만 보였다). + spec = industry_of(PlaceCategory(ctx.place.category).value)["sections"] return site_payload._sections(theme.get("sections"), spec), theme diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py index 0fe4943..4cd9c7d 100644 --- a/solution/backend/services/auth_service.py +++ b/solution/backend/services/auth_service.py @@ -11,6 +11,7 @@ from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD from router.v1.auth.protocol import ( Req_GoogleLogin, + Req_KakaoLogin, Req_Signup, Req_UpdateMe, Res_Login, @@ -24,6 +25,12 @@ from router.v1.validator.dependencies import ( GetHashedPW, VerifyPW, ) +from services.external.kakao_identity import ( + KakaoAccount, + KakaoNotConfigured, + KakaoTokenInvalid, + verify_access_token as verify_kakao_token, +) from services.external.google_identity import ( GoogleAccount, GoogleNotConfigured, @@ -46,6 +53,11 @@ def _google_login_id(sub: str) -> str: return f"{_SOCIAL_ID_PREFIX}{sub}" +def _kakao_login_id(uid: str) -> str: + """카카오 계정의 로그인 아이디. 구글과 접두어를 달리해 **수단이 섞이지 않게** 한다.""" + return f"kakao_{uid}" + + def _fit(value: str | None, limit: int) -> str | None: """컬럼 길이에 맞춰 자른다.""" if value is None: @@ -248,6 +260,55 @@ class AuthService: LOG.i(f"SIGNUP(google) : sub={account.sub}") return await self._finish_login(user) + async def kakao_login(self, req: Req_KakaoLogin) -> Res_Login: + """카카오 액세스 토큰 → 우리 세션. + + ★ google_login 과 **같은 세 갈래**다(기존 계정 → 이메일 충돌 → 신규). 규칙을 한 벌로 + 유지하려고 모양을 맞췄다 — 여기만 다르게 두면 "어느 쪽이 맞나" 를 매번 되짚게 된다. + ★★ 판정 키는 카카오 **회원번호**다. 이 값이 챗봇 웹훅의 `appUserId` 와 같아서, + 로그인만 해 두면 채널에 말을 거는 순간 자동으로 이어진다 + (services/kakao_link_service.link_by_app_user_id).""" + res = Res_Login() + + try: + account: KakaoAccount = await verify_kakao_token(req.access_token) + except KakaoNotConfigured: + res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED) + return res + except KakaoTokenInvalid: + res.result.SetResult(ErrorType.OAUTH_INVALID_TOKEN) + return res + + err_type, user = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_user_by_provider_uid(s, AuthProvider.KAKAO.value, account.uid), + ) + if err_type == ErrorType.SUCCESS and user is not None: + LOG.i("LOGIN(kakao)") + return await self._finish_login(user) + + # ★ 같은 이메일이 다른 수단으로 가입돼 있으면 **잇지 않는다**(DECISIONS 1절). + # 자동으로 이으면 남이 먼저 만들어 둔 계정에 그 사람의 카카오 로그인이 들어간다. + if account.email and await self._email_taken(account.email): + res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) + return res + + err_type, user = await self._create_account( + login_id=_kakao_login_id(account.uid), + password_hash=None, + name=account.name or None, + email=account.email or None, + provider=AuthProvider.KAKAO, + provider_uid=account.uid, + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + LOG.i("SIGNUP(kakao)") + return await self._finish_login(user) + async def get_me(self, user_info: UserInfo) -> Res_Me: res = Res_Me() diff --git a/solution/backend/services/external/kakao_identity.py b/solution/backend/services/external/kakao_identity.py new file mode 100644 index 0000000..a8f632b --- /dev/null +++ b/solution/backend/services/external/kakao_identity.py @@ -0,0 +1,93 @@ +"""카카오 로그인 — 액세스 토큰으로 "이 사람이 누구인가" 만 본다. + +★ 구글(google_identity)과 검증 방식이 다르다. 구글은 ID 토큰을 **우리가 직접** 서명·수신자까지 + 뜯어보지만, 카카오는 액세스 토큰을 카카오에 되물어 확인한다(`/v1/user/access_token_info`). + 그 응답의 `app_id` 가 우리 앱인지 대조하는 것이 구글의 `aud` 검사에 해당한다 — + **이 검사가 유일하게 "남의 앱에 발급된 진짜 카카오 토큰" 을 막는다.** + +★★ 여기서 얻는 회원번호(`id`)가 **챗봇 웹훅의 `user.properties.appUserId` 와 같은 값**이다 + (카카오 공식 문서: "앱 키가 정상적으로 등록된 경우, 카카오 로그인으로 받는 값과 동일"). + 그래서 봇에 물린 앱과 **같은 앱**이어야 채널 매칭이 된다 — 앱이 다르면 로그인은 되는데 + 매칭만 조용히 안 된다. +""" + +from dataclasses import dataclass + +import httpx + +from common.logger import LOG +from config.server_configs import kakao_login_config + +_TOKEN_INFO_URL = "https://kapi.kakao.com/v1/user/access_token_info" +_USER_ME_URL = "https://kapi.kakao.com/v2/user/me" + +_HTTP_TIMEOUT_SEC = 5.0 + + +class KakaoNotConfigured(RuntimeError): + """KAKAO_LOGIN_APP_ID 미설정 — 카카오 로그인만 꺼진다(서버는 뜬다).""" + + +class KakaoTokenInvalid(RuntimeError): + """만료·위조이거나 **남의 앱에 발급된** 토큰이다.""" + + +@dataclass +class KakaoAccount: + """토큰으로 확인한 신원. + + ★ uid 가 판정 키다. 이메일은 동의 항목이라 **없을 수 있고** 바뀔 수도 있다 — + 구글에서 sub 를 쓰는 것과 같은 이유다.""" + + uid: str # 카카오 회원번호. 챗봇의 appUserId 와 같은 값이다. + email: str = "" + name: str = "" + + +def is_configured() -> bool: + return bool(kakao_login_config.app_id) + + +async def verify_access_token(access_token: str) -> KakaoAccount: + """액세스 토큰 → 신원. 실패는 두 예외 중 하나로만 나간다.""" + if not is_configured(): + raise KakaoNotConfigured("KAKAO_LOGIN_APP_ID 가 설정되지 않았다") + if not access_token: + raise KakaoTokenInvalid("빈 토큰") + + headers = {"Authorization": f"Bearer {access_token}"} + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: + try: + info = await client.get(_TOKEN_INFO_URL, headers=headers) + 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})") + + 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 = "" + 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__}") + + return KakaoAccount(uid=uid, email=email, name=name) diff --git a/solution/backend/services/kakao_link_service.py b/solution/backend/services/kakao_link_service.py index 32ccd0f..d502544 100644 --- a/solution/backend/services/kakao_link_service.py +++ b/solution/backend/services/kakao_link_service.py @@ -8,8 +8,10 @@ from uuid import UUID from sqlalchemy import select, text, update from common.database.db_session_manager import DB_SESSION_MNG +from common.logger import LOG from common.database.model.models import owner_kakao_links as Link -from common.enums import KakaoLinkStatus +from common.database.model.models import users +from common.enums import AuthProvider, ErrorType, KakaoLinkStatus from config import agent_config as config # 사장님이 카톡 대화창에 손으로 친다. @@ -135,6 +137,65 @@ async def redeem(code: str, channel_user_key: str) -> UUID: return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) +async def link_by_app_user_id(app_user_id: str, channel_user_key: str) -> UUID | None: + """카카오 로그인으로 가입한 사장님을 **코드 없이** 채널에 잇는다. + + ★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다 + (카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 로그인만 해 두면 채널에 말을 거는 + 순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다. + ★ 그래도 **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 appUserId 가 + 없고, 봇에 앱키가 안 물린 환경에서는 이 값 자체가 안 온다 — 그때 유일한 길이다. + ★ 이미 그 카톡이 **다른 사장님**에게 묶여 있으면 잇지 않는다(부분 유니크가 막는다). + 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다. + + 반환: 이어진 사장님의 user_id. 못 이으면 None.""" + if not app_user_id or not channel_user_key: + return None + + async def run(s): + user = ( + await s.execute( + select(users).where( + users.provider == AuthProvider.KAKAO.value, + users.provider_uid == str(app_user_id), + users.deleted.is_(False), + ) + ) + ).scalars().first() + if user is None: + return ErrorType.SUCCESS, None + + await _lock_user(s, user.user_id) + taken = ( + await s.execute( + select(Link).where( + Link.channel_user_key == channel_user_key, + Link.deleted.is_(False), + Link.status == KakaoLinkStatus.LINKED.value, + ) + ) + ).scalars().first() + if taken is not None: + # 이미 이어져 있으면 그대로 둔다 — 같은 사람이면 성공, 다른 사람이면 빼앗지 않는다. + return ErrorType.SUCCESS, (user.user_id if taken.user_id == user.user_id else None) + + row = await _active(s, user.user_id) + if row is None: + row = Link(user_id=user.user_id, status=KakaoLinkStatus.LINKED.value) + s.add(row) + row.status = KakaoLinkStatus.LINKED.value + row.channel_user_key = channel_user_key + row.linked_at = _now() + row.code_sha = None + row.code_expires_at = None + return ErrorType.SUCCESS, user.user_id + + _err, user_id = await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) + if user_id is not None: + LOG.i("[kakao-link] 카카오 로그인 계정과 채널을 자동으로 이었다") + return user_id + + async def resolve(channel_user_key: str) -> UUID | None: """채널 발화자 → user_id.""" diff --git a/solution/backend/tests/test_kakao_link.py b/solution/backend/tests/test_kakao_link.py index 5cd054c..cd90f00 100644 --- a/solution/backend/tests/test_kakao_link.py +++ b/solution/backend/tests/test_kakao_link.py @@ -179,3 +179,64 @@ def test_코드에는_헷갈리는_글자가_없다(): """잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다.""" assert not set("01OILl") & set(service._CODE_ALPHABET) assert len(service._new_code()) == service._CODE_LENGTH + + +# ── 카카오 로그인 자동 매칭 (2026-09-30) ──────────────────────────────── + +async def _kakao_user(db_engine, uid: str): + """카카오로 가입한 사장님 하나. provider_uid 가 곧 챗봇의 appUserId 다.""" + from common.enums import AuthProvider + + user_id = uuid.uuid4() + async with db_engine.begin() as c: + await c.execute( + text("""INSERT INTO users(user_id, id, name, provider, provider_uid, status, role) + VALUES (:u, :id, '테스트', :p, :uid, 1, 1)"""), + {"u": user_id, "id": f"kakao_{uid}", "p": AuthProvider.KAKAO.value, "uid": uid}, + ) + return user_id + + +async def test_카카오로_가입했으면_코드_없이_이어진다(db_engine): + """★ appUserId 가 카카오 로그인 회원번호와 같은 값이라, 말을 거는 순간 알아볼 수 있다.""" + user_id = await _kakao_user(db_engine, "900001") + assert await service.resolve("kakao-speaker-a") is None + + linked = await service.link_by_app_user_id("900001", "kakao-speaker-a") + assert linked == user_id + assert await service.resolve("kakao-speaker-a") == user_id + + +async def test_카카오로_가입하지_않았으면_자동으로_잇지_않는다(db_engine): + """id/pw·구글 가입자에게는 appUserId 가 없다 — 코드 경로가 유일한 길이다.""" + assert await service.link_by_app_user_id("없는번호", "kakao-speaker-b") is None + assert await service.resolve("kakao-speaker-b") is None + # 값이 비어 있어도 조용히 지나간다. + assert await service.link_by_app_user_id("", "kakao-speaker-b") is None + assert await service.link_by_app_user_id("900001", "") is None + + +async def test_남의_카톡을_빼앗지_않는다(db_engine): + """★ 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다.""" + first = await _kakao_user(db_engine, "900002") + second = await _kakao_user(db_engine, "900003") + await service.link_by_app_user_id("900002", "kakao-speaker-c") + + # 다른 사장님이 같은 카톡으로 오면 잇지 않는다. + assert await service.link_by_app_user_id("900003", "kakao-speaker-c") is None + assert await service.resolve("kakao-speaker-c") == first + assert second is not None + + +async def test_같은_사람이_다시_말을_걸어도_그대로다(db_engine): + """이미 이어져 있으면 성공으로 답하고 아무것도 바꾸지 않는다.""" + user_id = await _kakao_user(db_engine, "900004") + assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id + assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id + + async with db_engine.begin() as c: + n = (await c.execute( + text("SELECT count(*) FROM owner_kakao_links WHERE user_id=:u AND deleted=false"), + {"u": user_id}, + )).scalar_one() + assert n == 1 diff --git a/solution/backend/tests/test_kakao_webhook.py b/solution/backend/tests/test_kakao_webhook.py index 10b1616..4275fa0 100644 --- a/solution/backend/tests/test_kakao_webhook.py +++ b/solution/backend/tests/test_kakao_webhook.py @@ -323,7 +323,7 @@ async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든 sent = {} - async def fake_push(callback_url, utterance, speaker): + async def fake_push(callback_url, utterance, speaker, app_user_id=""): sent["url"] = callback_url sent["payload"] = await bot._answer(utterance, speaker, 5.0)