diff --git a/backend/app.py b/backend/app.py index 38df3e2..2ce3c45 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3,8 +3,10 @@ Flask 웹 서버 - QR 마일리지 적립 간편 적립: 전화번호 + 이름만 입력 """ -from flask import Flask, request, render_template, jsonify, redirect, url_for +from flask import Flask, request, render_template, jsonify, redirect, url_for, session import hashlib +import base64 +import secrets from datetime import datetime, timezone, timedelta import sys import os @@ -452,6 +454,67 @@ def claim_mileage(user_id, token_info): return (False, f"적립 처리 실패: {str(e)}", 0) +def normalize_kakao_phone(kakao_phone): + """ + 카카오 전화번호 형식 변환 + "+82 10-1234-5678" → "01012345678" + """ + if not kakao_phone: + return None + digits = kakao_phone.replace('+', '').replace('-', '').replace(' ', '') + if digits.startswith('82'): + digits = '0' + digits[2:] + if len(digits) >= 10: + return digits + return None + + +def link_kakao_identity(user_id, kakao_id, kakao_data): + """카카오 계정을 customer_identities에 연결""" + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT id FROM customer_identities + WHERE provider = 'kakao' AND provider_user_id = ? + """, (kakao_id,)) + + if not cursor.fetchone(): + # raw_data 제외 (세션 크기 절약) + store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'} + cursor.execute(""" + INSERT INTO customer_identities (user_id, provider, provider_user_id, provider_data) + VALUES (?, 'kakao', ?, ?) + """, (user_id, kakao_id, json.dumps(store_data, ensure_ascii=False))) + + # 프로필 이미지, 이메일 업데이트 + updates = [] + params = [] + if kakao_data.get('profile_image'): + updates.append("profile_image_url = ?") + params.append(kakao_data['profile_image']) + if kakao_data.get('email'): + updates.append("email = ?") + params.append(kakao_data['email']) + if updates: + params.append(user_id) + cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params) + + conn.commit() + + +def find_user_by_kakao_id(kakao_id): + """카카오 ID로 기존 연결된 사용자 조회""" + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT user_id FROM customer_identities + WHERE provider = 'kakao' AND provider_user_id = ? + """, (kakao_id,)) + row = cursor.fetchone() + return row['user_id'] if row else None + + # ============================================================================ # 라우트 # ============================================================================ @@ -640,6 +703,197 @@ def api_claim(): }), 500 +# ============================================================================ +# 카카오 적립 라우트 +# ============================================================================ + +@app.route('/claim/kakao/start') +def claim_kakao_start(): + """카카오 OAuth 시작 - claim 컨텍스트를 state에 담아 카카오로 리다이렉트""" + from services.kakao_client import get_kakao_client + + token_param = request.args.get('t', '') + if ':' not in token_param: + return render_template('error.html', message="잘못된 QR 코드입니다.") + + parts = token_param.split(':') + if len(parts) != 2: + return render_template('error.html', message="잘못된 QR 코드입니다.") + + transaction_id, nonce = parts + + # 토큰 사전 검증 + success, message, token_info = verify_claim_token(transaction_id, nonce) + if not success: + return render_template('error.html', message=message) + + # state: claim 컨텍스트 + CSRF 토큰 + csrf_token = secrets.token_hex(16) + state_data = { + 't': token_param, + 'csrf': csrf_token + } + state_encoded = base64.urlsafe_b64encode( + json.dumps(state_data).encode() + ).decode() + + session['kakao_csrf'] = csrf_token + + kakao_client = get_kakao_client() + auth_url = kakao_client.get_authorization_url(state=state_encoded) + + return redirect(auth_url) + + +@app.route('/claim/kakao/callback') +def claim_kakao_callback(): + """카카오 OAuth 콜백 - 토큰 교환 → 사용자 정보 → 적립 처리""" + from services.kakao_client import get_kakao_client + + code = request.args.get('code') + state_encoded = request.args.get('state') + error = request.args.get('error') + + if error: + return render_template('error.html', message="카카오 로그인이 취소되었습니다.") + + if not code or not state_encoded: + return render_template('error.html', message="카카오 인증 정보가 올바르지 않습니다.") + + # 1. state 디코딩 + try: + state_json = base64.urlsafe_b64decode(state_encoded).decode() + state_data = json.loads(state_json) + except Exception: + return render_template('error.html', message="인증 상태 정보가 올바르지 않습니다.") + + # 2. CSRF 검증 + csrf_token = state_data.get('csrf') + if csrf_token != session.pop('kakao_csrf', None): + return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.") + + # 3. claim 컨텍스트 복원 + token_param = state_data.get('t', '') + parts = token_param.split(':') + if len(parts) != 2: + return render_template('error.html', message="적립 정보가 올바르지 않습니다.") + + transaction_id, nonce = parts + + # 4. 토큰 재검증 (OAuth 중 다른 사람이 적립했을 수 있음) + success, message, token_info = verify_claim_token(transaction_id, nonce) + if not success: + return render_template('error.html', message=message) + + # 5. 카카오 access_token 교환 + kakao_client = get_kakao_client() + success, token_data = kakao_client.get_access_token(code) + if not success: + logging.error(f"카카오 토큰 교환 실패: {token_data}") + return render_template('error.html', message="카카오 인증에 실패했습니다. 다시 시도해주세요.") + + # 6. 사용자 정보 조회 + access_token = token_data.get('access_token') + success, user_info = kakao_client.get_user_info(access_token) + if not success: + logging.error(f"카카오 사용자 정보 조회 실패: {user_info}") + return render_template('error.html', message="카카오 사용자 정보를 가져올 수 없습니다.") + + kakao_id = user_info.get('kakao_id') + kakao_name = user_info.get('name') or user_info.get('nickname', '') + kakao_phone_raw = user_info.get('phone_number') + kakao_phone = normalize_kakao_phone(kakao_phone_raw) + + # 7. 분기: 전화번호가 있으면 자동 적립, 없으면 폰 입력 폼 + if kakao_phone: + # 자동 적립 + existing_user_id = find_user_by_kakao_id(kakao_id) + if existing_user_id: + user_id = existing_user_id + is_new = False + else: + user_id, is_new = get_or_create_user(kakao_phone, kakao_name) + + link_kakao_identity(user_id, kakao_id, user_info) + + success, msg, new_balance = claim_mileage(user_id, token_info) + if not success: + return render_template('error.html', message=msg) + + return render_template('claim_kakao_success.html', + points=token_info['claimable_points'], + balance=new_balance, + phone=kakao_phone, + name=kakao_name) + else: + # 전화번호 없음 → 세션에 카카오 정보 저장 + 폰 입력 폼 + session['kakao_claim'] = { + 'kakao_id': kakao_id, + 'name': kakao_name, + 'profile_image': user_info.get('profile_image'), + 'email': user_info.get('email'), + 'token_param': token_param + } + + return render_template('claim_kakao_phone.html', + token_info=token_info, + kakao_name=kakao_name, + kakao_profile_image=user_info.get('profile_image')) + + +@app.route('/api/claim/kakao', methods=['POST']) +def api_claim_kakao(): + """카카오 적립 (전화번호 폴백) - 세션의 카카오 정보 + 폼의 전화번호로 적립""" + kakao_data = session.pop('kakao_claim', None) + if not kakao_data: + return jsonify({ + 'success': False, + 'message': '카카오 인증 정보가 만료되었습니다. 다시 시도해주세요.' + }), 400 + + data = request.get_json() + phone = data.get('phone', '').strip().replace('-', '').replace(' ', '') + + if len(phone) < 10: + session['kakao_claim'] = kakao_data + return jsonify({ + 'success': False, + 'message': '올바른 전화번호를 입력해주세요.' + }), 400 + + token_param = kakao_data['token_param'] + parts = token_param.split(':') + transaction_id, nonce = parts + + success, message, token_info = verify_claim_token(transaction_id, nonce) + if not success: + return jsonify({'success': False, 'message': message}), 400 + + kakao_id = kakao_data['kakao_id'] + name = kakao_data['name'] + + existing_user_id = find_user_by_kakao_id(kakao_id) + if existing_user_id: + user_id = existing_user_id + is_new = False + else: + user_id, is_new = get_or_create_user(phone, name) + + link_kakao_identity(user_id, kakao_id, kakao_data) + + success, message, new_balance = claim_mileage(user_id, token_info) + if not success: + return jsonify({'success': False, 'message': message}), 500 + + return jsonify({ + 'success': True, + 'message': message, + 'points': token_info['claimable_points'], + 'balance': new_balance, + 'is_new_user': is_new + }) + + @app.route('/my-page') def my_page(): """마이페이지 (전화번호로 조회)""" diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/kakao_client.py b/backend/services/kakao_client.py new file mode 100644 index 0000000..f3f7457 --- /dev/null +++ b/backend/services/kakao_client.py @@ -0,0 +1,157 @@ +""" +Kakao API Client for OAuth 2.0 Authentication +pharmacy-pos-qr-system용 경량 버전 +""" +import os +import requests +import json +import logging +from datetime import datetime, timedelta +from typing import Dict, Any, Tuple +from urllib.parse import urlencode + +logger = logging.getLogger(__name__) + + +class KakaoAPIClient: + """카카오 API 클라이언트 (마일리지 적립용)""" + + def __init__(self): + self.client_id = os.getenv('KAKAO_CLIENT_ID', '') + self.client_secret = os.getenv('KAKAO_CLIENT_SECRET', '') + self.redirect_uri = os.getenv( + 'KAKAO_REDIRECT_URI', + 'https://mile.0bin.in/claim/kakao/callback' + ) + + self.auth_base_url = 'https://kauth.kakao.com' + self.api_base_url = 'https://kapi.kakao.com' + + self.session = requests.Session() + self.session.headers.update({ + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'YouthPharmacy-Mileage/1.0' + }) + + def get_authorization_url(self, state: str = None) -> str: + """카카오 OAuth 인증 URL 생성""" + params = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'profile_nickname,profile_image,account_email,name,phone_number' + } + + if state: + params['state'] = state + + return f"{self.auth_base_url}/oauth/authorize?{urlencode(params)}" + + def get_access_token(self, authorization_code: str) -> Tuple[bool, Dict[str, Any]]: + """Authorization Code로 Access Token 요청""" + url = f"{self.auth_base_url}/oauth/token" + + data = { + 'grant_type': 'authorization_code', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'redirect_uri': self.redirect_uri, + 'code': authorization_code + } + + try: + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + response = self.session.post(url, data=data, headers=headers) + + logger.info(f"카카오 토큰 응답 상태: {response.status_code}") + response.raise_for_status() + + token_data = response.json() + + if 'expires_in' in token_data: + expires_at = datetime.now() + timedelta(seconds=token_data['expires_in']) + token_data['expires_at'] = expires_at.isoformat() + + return True, token_data + + except requests.exceptions.RequestException as e: + logger.error(f"카카오 토큰 요청 실패: {e}") + + error_details = { + 'error': 'token_request_failed', + 'error_description': f'Failed to get access token: {e}' + } + try: + if hasattr(e, 'response') and e.response is not None: + kakao_error = e.response.json() + logger.error(f"카카오 API 오류: {kakao_error}") + error_details.update(kakao_error) + except Exception: + pass + + return False, error_details + + except json.JSONDecodeError as e: + logger.error(f"카카오 응답 JSON 파싱 실패: {e}") + return False, { + 'error': 'invalid_response', + 'error_description': f'Invalid JSON response: {e}' + } + + def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]: + """Access Token으로 사용자 정보 조회""" + url = f"{self.api_base_url}/v2/user/me" + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/x-www-form-urlencoded' + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + + user_data = response.json() + normalized_user = self._normalize_user_data(user_data) + + return True, normalized_user + + except requests.exceptions.RequestException as e: + logger.error(f"카카오 사용자 정보 조회 실패: {e}") + return False, { + 'error': 'user_info_failed', + 'error_description': f'Failed to get user info: {e}' + } + + def _normalize_user_data(self, kakao_user: Dict[str, Any]) -> Dict[str, Any]: + """카카오 사용자 데이터를 정규화""" + kakao_account = kakao_user.get('kakao_account', {}) + profile = kakao_account.get('profile', {}) + + normalized = { + 'kakao_id': str(kakao_user.get('id')), + 'nickname': profile.get('nickname'), + 'profile_image': profile.get('profile_image_url'), + 'thumbnail_image': profile.get('thumbnail_image_url'), + 'email': kakao_account.get('email'), + 'is_email_verified': kakao_account.get('is_email_verified', False), + 'name': kakao_account.get('name'), + 'phone_number': kakao_account.get('phone_number'), + } + + # None 값 제거 + normalized = {k: v for k, v in normalized.items() if v is not None} + + return normalized + + +# 싱글톤 +_kakao_client = None + + +def get_kakao_client() -> KakaoAPIClient: + """카카오 클라이언트 인스턴스 반환""" + global _kakao_client + if _kakao_client is None: + _kakao_client = KakaoAPIClient() + return _kakao_client diff --git a/backend/templates/claim_form.html b/backend/templates/claim_form.html index 83fc7b5..23375d4 100644 --- a/backend/templates/claim_form.html +++ b/backend/templates/claim_form.html @@ -557,6 +557,23 @@ +
+ 또는 +
+
+ + + + + + 카카오로 적립하기 + +
diff --git a/backend/templates/claim_kakao_phone.html b/backend/templates/claim_kakao_phone.html new file mode 100644 index 0000000..a25d544 --- /dev/null +++ b/backend/templates/claim_kakao_phone.html @@ -0,0 +1,442 @@ + + + + + + 카카오 적립 - 청춘약국 + + + + + + +
+
+
+
청춘약국
+
포인트 적립
+
+
+ +
+
+
+ {% if kakao_profile_image %} + 프로필 + {% else %} +
+ + + +
+ {% endif %} +
{{ kakao_name }}님
+ + + + + 카카오 인증됨 + +
+ +
+
적립 포인트
+
+{{ "{:,}".format(token_info.claimable_points) }}P
+
+ +
+
+ +
+ 010 - + +
+
+ +
+ +
+
+
+ +
+
+ + + +
+
적립 완료!
+
0P
+
+ 총 포인트 0P +
+ +
+
+ + + + diff --git a/backend/templates/claim_kakao_success.html b/backend/templates/claim_kakao_success.html new file mode 100644 index 0000000..11a2f1d --- /dev/null +++ b/backend/templates/claim_kakao_success.html @@ -0,0 +1,187 @@ + + + + + + 적립 완료 - 청춘약국 + + + + + + +
+
+ + + +
+
적립 완료!
+
{{ name }}
+
+{{ "{:,}".format(points) }}P
+
+ 총 포인트 {{ "{:,}".format(balance) }}P +
+
+ + + + 카카오로 적립됨 +
+
+ 홈으로 + 내역 보기 +
+
+ + diff --git a/backend/templates/my_page.html b/backend/templates/my_page.html index f350d74..3d1ff56 100644 --- a/backend/templates/my_page.html +++ b/backend/templates/my_page.html @@ -28,20 +28,20 @@ margin: 0 auto; } - .header { - background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); - padding: 48px 24px 32px 24px; - color: #ffffff; - } - .header-top { + position: sticky; + top: 0; + z-index: 100; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + padding: 0 24px; + height: 56px; display: flex; justify-content: space-between; align-items: center; - margin-bottom: 24px; } .header-title { + color: #ffffff; font-size: 20px; font-weight: 700; letter-spacing: -0.3px; @@ -55,6 +55,12 @@ letter-spacing: -0.2px; } + .header-profile { + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + padding: 28px 24px 32px 24px; + color: #ffffff; + } + .user-info { text-align: center; padding: 20px 0; @@ -254,20 +260,21 @@ /* 모바일 최적화 */ @media (max-width: 480px) { - .header { - padding-top: 60px; + .header-top { + padding-top: env(safe-area-inset-top, 0px); + height: calc(56px + env(safe-area-inset-top, 0px)); } }
-
-
-
마이페이지
- 다른 번호로 조회 -
+
+
마이페이지
+ 다른 번호로 조회 +
+