feat: 카카오 로그인으로 마일리지 적립 기능 추가

- 카카오 OAuth 2.0 클라이언트 모듈 추가 (services/kakao_client.py)
- 적립 페이지에 "카카오로 적립하기" 버튼 추가
- OAuth 콜백 처리: 전화번호 자동 적립 / 미제공 시 폰 입력 폴백
- state 파라미터로 claim 컨텍스트 보존 + CSRF 보호
- customer_identities 테이블 활용한 카카오 계정 연결
- 마이페이지 헤더 sticky 고정
- 카카오 OAuth 설정 가이드 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thug0bin 2026-02-25 02:27:05 +09:00
parent 82220a4a44
commit 31cf6e3816
8 changed files with 1188 additions and 15 deletions

View File

@ -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():
"""마이페이지 (전화번호로 조회)"""

View File

View File

@ -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

View File

@ -557,6 +557,23 @@
</button>
</form>
<div style="text-align: center; margin: 20px 0 16px 0; position: relative;">
<span style="background: #fff; padding: 0 16px; color: #adb5bd; font-size: 13px; font-weight: 500; position: relative; z-index: 1;">또는</span>
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #e9ecef; z-index: 0;"></div>
</div>
<a href="/claim/kakao/start?t={{ request.args.get('t') }}"
style="display: flex; align-items: center; justify-content: center; gap: 8px;
width: 100%; padding: 16px; background: #FEE500; color: #191919;
border: none; border-radius: 14px; font-size: 16px; font-weight: 600;
text-decoration: none; letter-spacing: -0.3px; transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
</svg>
카카오로 적립하기
</a>
<div class="alert error" id="alertMsg"></div>
</div>
</div>

View File

@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>카카오 적립 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
-webkit-font-smoothing: antialiased;
}
.app-container {
background: #ffffff;
border-radius: 24px;
max-width: 420px;
width: 100%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 32px 24px 140px 24px;
position: relative;
}
.header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 120px;
background: #ffffff;
border-radius: 32px 32px 0 0;
}
.header-content {
position: relative;
z-index: 2;
}
.pharmacy-name {
color: rgba(255, 255, 255, 0.9);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.2px;
margin-bottom: 4px;
}
.header-title {
color: #ffffff;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.5px;
}
.card {
background: #ffffff;
border-radius: 20px;
padding: 24px;
margin: -100px 24px 24px 24px;
position: relative;
z-index: 3;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
.kakao-profile {
text-align: center;
padding: 20px 0;
border-bottom: 1px solid #f1f3f5;
margin-bottom: 24px;
}
.kakao-profile img {
width: 64px;
height: 64px;
border-radius: 50%;
margin-bottom: 12px;
border: 3px solid #FEE500;
}
.kakao-profile-name {
font-size: 18px;
font-weight: 700;
color: #212529;
letter-spacing: -0.3px;
margin-bottom: 4px;
}
.kakao-profile-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #191919;
background: #FEE500;
padding: 4px 10px;
border-radius: 20px;
font-weight: 600;
}
.points-info {
text-align: center;
padding: 16px 0 24px 0;
}
.points-label {
color: #868e96;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
}
.points-value {
color: #6366f1;
font-size: 36px;
font-weight: 700;
letter-spacing: -1px;
}
.form-section { margin-top: 0; }
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: block;
color: #495057;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
letter-spacing: -0.2px;
}
.input-wrapper input {
width: 100%;
padding: 16px 18px;
border: 2px solid #e9ecef;
border-radius: 14px;
font-size: 16px;
font-weight: 500;
transition: all 0.2s ease;
background: #f8f9fa;
letter-spacing: -0.3px;
}
.input-wrapper input:focus {
outline: none;
border-color: #6366f1;
background: #ffffff;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
}
.input-wrapper input::placeholder {
color: #adb5bd;
font-weight: 400;
}
.btn-submit {
width: 100%;
padding: 18px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #ffffff;
border: none;
border-radius: 14px;
font-size: 17px;
font-weight: 700;
cursor: pointer;
letter-spacing: -0.3px;
transition: all 0.2s ease;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.24);
}
.btn-submit:active { transform: scale(0.98); }
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; }
.alert {
display: none;
padding: 14px 16px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
margin-top: 16px;
letter-spacing: -0.2px;
}
.alert.error {
background: #fff5f5;
color: #e03131;
border: 1px solid #ffc9c9;
}
/* 성공 화면 */
.success-screen {
display: none;
text-align: center;
padding: 60px 24px;
}
.success-icon-wrap {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 50%;
margin: 0 auto 24px auto;
display: flex;
align-items: center;
justify-content: center;
}
.success-icon-wrap svg {
width: 40px;
height: 40px;
fill: none;
stroke: #ffffff;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.success-title {
font-size: 24px;
font-weight: 700;
color: #212529;
margin-bottom: 16px;
letter-spacing: -0.5px;
}
.success-points {
font-size: 48px;
font-weight: 700;
color: #6366f1;
margin-bottom: 8px;
letter-spacing: -1.5px;
}
.success-balance {
color: #868e96;
font-size: 15px;
font-weight: 500;
margin-bottom: 32px;
letter-spacing: -0.2px;
}
.success-balance strong {
color: #495057;
}
.button-group {
display: flex;
gap: 12px;
}
.btn-secondary, .btn-primary {
flex: 1;
padding: 16px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
text-decoration: none;
text-align: center;
transition: all 0.2s ease;
letter-spacing: -0.2px;
}
.btn-secondary {
background: #f1f3f5;
color: #495057;
}
.btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #ffffff;
}
@media (max-width: 480px) {
body { padding: 0; }
.app-container { border-radius: 0; min-height: 100vh; }
.header { padding-top: 48px; }
}
</style>
</head>
<body>
<div class="app-container">
<div class="header">
<div class="header-content">
<div class="pharmacy-name">청춘약국</div>
<div class="header-title">포인트 적립</div>
</div>
</div>
<div id="claimForm">
<div class="card">
<div class="kakao-profile">
{% if kakao_profile_image %}
<img src="{{ kakao_profile_image }}" alt="프로필">
{% else %}
<div style="width: 64px; height: 64px; border-radius: 50%; background: #FEE500; margin: 0 auto 12px auto; display: flex; align-items: center; justify-content: center;">
<svg width="28" height="28" viewBox="0 0 20 20" fill="none">
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
</svg>
</div>
{% endif %}
<div class="kakao-profile-name">{{ kakao_name }}님</div>
<span class="kakao-profile-badge">
<svg width="12" height="12" viewBox="0 0 20 20" fill="none">
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
</svg>
카카오 인증됨
</span>
</div>
<div class="points-info">
<div class="points-label">적립 포인트</div>
<div class="points-value">+{{ "{:,}".format(token_info.claimable_points) }}P</div>
</div>
<form id="formKakaoClaim" class="form-section">
<div class="input-group">
<label for="phone">전화번호를 입력해주세요</label>
<div class="input-wrapper" style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px; font-weight: 600; color: #495057; white-space: nowrap; padding: 16px 0 16px 4px;">010 -</span>
<input type="tel" id="phone"
placeholder="0000-0000"
inputmode="numeric"
maxlength="9"
autocomplete="tel"
required
style="flex: 1;">
</div>
</div>
<button type="submit" class="btn-submit" id="btnSubmit">포인트 적립하기</button>
</form>
<div class="alert error" id="alertMsg"></div>
</div>
</div>
<div id="successScreen" class="success-screen">
<div class="success-icon-wrap">
<svg viewBox="0 0 52 52">
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
<div class="success-title">적립 완료!</div>
<div class="success-points" id="successPoints">0P</div>
<div class="success-balance">
총 포인트 <strong id="successBalance">0P</strong>
</div>
<div class="button-group">
<a href="/" class="btn-secondary">홈으로</a>
<a href="#" class="btn-primary" id="btnMyPage">내역 보기</a>
</div>
</div>
</div>
<script>
const phoneInput = document.getElementById('phone');
const form = document.getElementById('formKakaoClaim');
const btnSubmit = document.getElementById('btnSubmit');
const alertMsg = document.getElementById('alertMsg');
// 자동 하이픈
phoneInput.addEventListener('input', function(e) {
let value = e.target.value.replace(/[^0-9]/g, '');
if (value.length <= 4) {
e.target.value = value;
} else {
e.target.value = value.slice(0, 4) + '-' + value.slice(4, 8);
}
});
phoneInput.focus();
form.addEventListener('submit', async function(e) {
e.preventDefault();
const phoneRaw = phoneInput.value.replace(/[^0-9]/g, '');
if (phoneRaw.length < 7) {
showAlert('전화번호를 정확히 입력해주세요.');
return;
}
const phone = '010' + phoneRaw;
btnSubmit.disabled = true;
btnSubmit.textContent = '처리 중...';
alertMsg.style.display = 'none';
try {
const response = await fetch('/api/claim/kakao', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: phone })
});
const data = await response.json();
if (data.success) {
showSuccess(data.points, data.balance, phone);
} else {
showAlert(data.message);
btnSubmit.disabled = false;
btnSubmit.textContent = '포인트 적립하기';
}
} catch (error) {
showAlert('네트워크 오류가 발생했습니다.');
btnSubmit.disabled = false;
btnSubmit.textContent = '포인트 적립하기';
}
});
function showAlert(msg) {
alertMsg.textContent = msg;
alertMsg.style.display = 'block';
setTimeout(() => { alertMsg.style.display = 'none'; }, 5000);
}
function showSuccess(points, balance, phone) {
document.getElementById('claimForm').style.display = 'none';
document.getElementById('successPoints').textContent = points.toLocaleString() + 'P';
document.getElementById('successBalance').textContent = balance.toLocaleString() + 'P';
document.getElementById('btnMyPage').href = '/my-page?phone=' + encodeURIComponent(phone);
document.getElementById('successScreen').style.display = 'block';
}
</script>
</body>
</html>

View File

@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>적립 완료 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
-webkit-font-smoothing: antialiased;
}
.app-container {
background: #ffffff;
border-radius: 24px;
max-width: 420px;
width: 100%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
overflow: hidden;
text-align: center;
padding: 60px 24px;
}
.success-icon-wrap {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 50%;
margin: 0 auto 24px auto;
display: flex;
align-items: center;
justify-content: center;
animation: scaleIn 0.4s ease;
}
@keyframes scaleIn {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
.success-icon-wrap svg {
width: 40px;
height: 40px;
fill: none;
stroke: #ffffff;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: drawCheck 0.6s 0.3s ease forwards;
}
@keyframes drawCheck {
to { stroke-dashoffset: 0; }
}
.success-title {
font-size: 24px;
font-weight: 700;
color: #212529;
margin-bottom: 16px;
letter-spacing: -0.5px;
}
.success-name {
font-size: 15px;
color: #868e96;
font-weight: 500;
margin-bottom: 20px;
}
.success-name strong {
color: #495057;
}
.success-points {
font-size: 48px;
font-weight: 700;
color: #6366f1;
margin-bottom: 8px;
letter-spacing: -1.5px;
}
.success-balance {
color: #868e96;
font-size: 15px;
font-weight: 500;
margin-bottom: 32px;
letter-spacing: -0.2px;
}
.success-balance strong {
color: #495057;
}
.kakao-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #191919;
background: #FEE500;
padding: 6px 14px;
border-radius: 20px;
font-weight: 600;
margin-bottom: 32px;
}
.button-group {
display: flex;
gap: 12px;
}
.btn-secondary, .btn-primary {
flex: 1;
padding: 16px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
text-decoration: none;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
letter-spacing: -0.2px;
}
.btn-secondary {
background: #f1f3f5;
color: #495057;
}
.btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #ffffff;
}
.btn-secondary:active, .btn-primary:active {
transform: scale(0.98);
}
@media (max-width: 480px) {
body { padding: 0; }
.app-container { border-radius: 0; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }
}
</style>
</head>
<body>
<div class="app-container">
<div class="success-icon-wrap">
<svg viewBox="0 0 52 52">
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
<div class="success-title">적립 완료!</div>
<div class="success-name"><strong>{{ name }}</strong></div>
<div class="success-points">+{{ "{:,}".format(points) }}P</div>
<div class="success-balance">
총 포인트 <strong>{{ "{:,}".format(balance) }}P</strong>
</div>
<div class="kakao-badge">
<svg width="14" height="14" viewBox="0 0 20 20" fill="none">
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
</svg>
카카오로 적립됨
</div>
<div class="button-group">
<a href="/" class="btn-secondary">홈으로</a>
<a href="/my-page?phone={{ phone }}" class="btn-primary">내역 보기</a>
</div>
</div>
</body>
</html>

View File

@ -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));
}
}
</style>
</head>
<body>
<div class="app-container">
<div class="header">
<div class="header-top">
<div class="header-title">마이페이지</div>
<a href="/my-page" class="btn-logout">다른 번호로 조회</a>
</div>
<div class="header-top">
<div class="header-title">마이페이지</div>
<a href="/my-page" class="btn-logout">다른 번호로 조회</a>
</div>
<div class="header-profile">
<div class="user-info">
<div class="user-name">{{ user.nickname }}님</div>
<div class="user-phone">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</div>

109
docs/kakao-oauth-setup.md Normal file
View File

@ -0,0 +1,109 @@
# 카카오 OAuth 설정 가이드
## 카카오 앱 정보
- **앱 ID**: 1165131
- **앱 이름**: 청춘약국
- **앱 유형**: 비즈 앱
- **개발자 콘솔**: https://developers.kakao.com/console/app/1165131
## Redirect URI 등록 (2025년 12월 개편 후)
> **주의**: 2025년 12월 카카오 콘솔 UI가 개편되면서 Redirect URI 위치가 변경됨.
> 기존에는 `카카오 로그인 > 일반`에 있었지만, 현재는 `앱 > 플랫폼 키` 하위로 이동됨.
### 현재 경로 (2025.12~ )
```
앱 > 플랫폼 키 > REST API 키 클릭 > 리다이렉트 URI
```
### 이전 경로 (~ 2025.11, 더 이상 사용 안 함)
```
카카오 로그인 > 일반 > Redirect URI ← 여기 더 이상 없음
```
### 등록된 Redirect URI 목록
| 서비스 | Redirect URI |
|--------|-------------|
| 게시판 (board-system) | `https://bbs.0bin.in/auth/kakao/callback` |
| 마일리지 적립 (pharmacy-pos-qr) | `https://mile.0bin.in/claim/kakao/callback` |
## 로그아웃 Redirect URI (별도)
로그아웃용 Redirect URI는 **다른 위치**에서 설정:
```
카카오 로그인 > 고급 > 로그아웃 리다이렉트 URI
```
로그인용 Redirect URI와 혼동하지 않도록 주의.
## 웹 도메인 등록
```
앱 > 제품 링크 관리 > 웹 도메인
```
등록된 도메인 (최대 10개):
- `https://img.0bin.in` (기본)
- `https://api.0bin.in`
- `https://0bin.in`
- `https://bbs.0bin.in`
- `https://drug.0bin.in`
- `https://ani.0bin.in`
- `https://figma.0bin.in`
- `https://am.0bin.in`
- `https://ka.0bin.in`
- `https://mile.0bin.in`
## 동의항목 설정
```
카카오 로그인 > 동의항목
```
| 항목 | ID | 용도 | 비즈앱 필요 |
|------|-----|------|------------|
| 닉네임 | profile_nickname | 사용자 이름 | X |
| 프로필 사진 | profile_image | 아바타 | X |
| 이메일 | account_email | 계정 연동 | X |
| 이름 (실명) | name | 마일리지 적립자명 | O |
| 전화번호 | phone_number | 마일리지 유저 매칭 | O |
## 환경변수
```bash
KAKAO_CLIENT_ID=<REST API >
KAKAO_CLIENT_SECRET=<카카오 개발자 콘솔 > 앱 > 보안에서 확인>
KAKAO_REDIRECT_URI=https://mile.0bin.in/claim/kakao/callback
```
### Client ID 확인 위치
```
앱 > 플랫폼 키 > REST API 키 > 키 값
```
### Client Secret 확인 위치
```
앱 > 보안 > Client Secret 코드
```
## 관련 파일
| 프로젝트 | 파일 | 설명 |
|---------|------|------|
| pharmacy-pos-qr-system | `backend/services/kakao_client.py` | 카카오 API 클라이언트 |
| pharmacy-pos-qr-system | `backend/app.py` | OAuth 라우트 (`/claim/kakao/*`) |
| board-system-project | `backend/services/kakao_client.py` | 카카오 API 클라이언트 (원본) |
| board-system-project | `backend/routes/auth.py` | OAuth 라우트 (`/auth/kakao/*`) |
## 참고 링크
- [카카오 로그인 REST API 문서](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api)
- [카카오 로그인 설정하기](https://developers.kakao.com/docs/latest/ko/kakaologin/prerequisite)
- [카카오 앱 키 구조 개편 공지 (2025.12)](https://devtalk.kakao.com/t/upcoming-kakao-developers-app-key-update/147295)