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:
256
backend/app.py
256
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():
|
||||
"""마이페이지 (전화번호로 조회)"""
|
||||
|
||||
Reference in New Issue
Block a user