feat: 반려동물 등록 기능 및 확장 마이페이지 추가

- pets 테이블 추가 (이름, 종류, 품종, 사진 등)
- 반려동물 CRUD API (/api/pets)
- 확장 마이페이지 (/mypage) - 카카오 로그인 기반
- 기존 마이페이지에 퀵 메뉴 추가 (반려동물/쿠폰/구매내역/내정보)
- 카카오 로그인 시 세션에 user_id 저장
- 동물약 APC 매핑 가이드 문서 추가
This commit is contained in:
thug0bin
2026-03-02 13:56:22 +09:00
parent f102f6b42e
commit 1cebb02ec6
7 changed files with 1656 additions and 16 deletions

View File

@@ -492,8 +492,8 @@ def normalize_kakao_phone(kakao_phone):
return None
def link_kakao_identity(user_id, kakao_id, kakao_data):
"""카카오 계정을 customer_identities에 연결"""
def link_kakao_identity(user_id, kakao_id, kakao_data, token_data=None):
"""카카오 계정을 customer_identities에 연결 (토큰 포함)"""
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
@@ -504,13 +504,33 @@ def link_kakao_identity(user_id, kakao_id, kakao_data):
is_new_link = cursor.fetchone() is None
store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'}
if is_new_link:
# 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)))
INSERT INTO customer_identities
(user_id, provider, provider_user_id, provider_data, access_token, refresh_token, token_expires_at)
VALUES (?, 'kakao', ?, ?, ?, ?, ?)
""", (
user_id, kakao_id,
json.dumps(store_data, ensure_ascii=False),
token_data.get('access_token') if token_data else None,
token_data.get('refresh_token') if token_data else None,
token_data.get('expires_at') if token_data else None,
))
elif token_data:
# 기존 레코드: 토큰 + 프로필 데이터 업데이트
cursor.execute("""
UPDATE customer_identities
SET access_token = ?, refresh_token = ?, token_expires_at = ?, provider_data = ?
WHERE provider = 'kakao' AND provider_user_id = ?
""", (
token_data.get('access_token'),
token_data.get('refresh_token'),
token_data.get('expires_at'),
json.dumps(store_data, ensure_ascii=False),
kakao_id,
))
# 프로필 이미지, 이메일 업데이트
updates = []
@@ -545,6 +565,52 @@ def find_user_by_kakao_id(kakao_id):
return row['user_id'] if row else None
def get_kakao_tokens(user_id):
"""사용자의 저장된 카카오 토큰 조회"""
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT provider_user_id, access_token, refresh_token, token_expires_at
FROM customer_identities
WHERE provider = 'kakao' AND user_id = ?
""", (user_id,))
row = cursor.fetchone()
if row and row['access_token']:
return {
'kakao_id': row['provider_user_id'],
'access_token': row['access_token'],
'refresh_token': row['refresh_token'],
'token_expires_at': row['token_expires_at'],
}
return None
def update_kakao_tokens(kakao_id, token_data):
"""카카오 토큰 업데이트 (갱신 후 저장용)"""
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
updates = ["access_token = ?"]
params = [token_data['access_token']]
if 'expires_at' in token_data:
updates.append("token_expires_at = ?")
params.append(token_data['expires_at'])
# refresh_token은 갱신 응답에 포함된 경우에만 업데이트
if 'refresh_token' in token_data:
updates.append("refresh_token = ?")
params.append(token_data['refresh_token'])
params.append(kakao_id)
cursor.execute(f"""
UPDATE customer_identities
SET {', '.join(updates)}
WHERE provider = 'kakao' AND provider_user_id = ?
""", params)
conn.commit()
# ============================================================================
# 라우트
# ============================================================================
@@ -807,7 +873,7 @@ def claim_kakao_start():
return redirect(auth_url)
def _handle_mypage_kakao_callback(code, kakao_client):
def _handle_mypage_kakao_callback(code, kakao_client, redirect_to=None):
"""
마이페이지 카카오 콜백 처리 - 카카오 연동(머지) + 마이페이지 이동
@@ -816,6 +882,9 @@ def _handle_mypage_kakao_callback(code, kakao_client):
B) 미연결 + 카카오 전화번호로 기존 유저 발견 → 카카오 연동 후 이동
C) 미연결 + 기존 유저 없음 → 신규 생성 + 카카오 연동
D) 전화번호 없음 → 에러 안내
Args:
redirect_to: 리다이렉트할 URL (None이면 기본 /my-page?phone=xxx)
"""
success, token_data = kakao_client.get_access_token(code)
if not success:
@@ -843,9 +912,15 @@ def _handle_mypage_kakao_callback(code, kakao_client):
"UPDATE users SET nickname = ? WHERE id = ? AND nickname = '고객'",
(kakao_name, existing_user_id))
conn.commit()
cursor.execute("SELECT phone FROM users WHERE id = ?", (existing_user_id,))
cursor.execute("SELECT phone, nickname FROM users WHERE id = ?", (existing_user_id,))
row = cursor.fetchone()
if row and row['phone']:
# 세션에 로그인 정보 저장
session['logged_in_user_id'] = existing_user_id
session['logged_in_phone'] = row['phone']
session['logged_in_name'] = row['nickname'] or kakao_name
if redirect_to:
return redirect(redirect_to)
return redirect(f"/my-page?phone={row['phone']}")
# 전화번호 없으면 연동 불가
@@ -860,7 +935,7 @@ def _handle_mypage_kakao_callback(code, kakao_client):
if phone_user:
# Case B: 기존 전화번호 유저 → 카카오 연동 (머지)
user_id = phone_user['id']
link_kakao_identity(user_id, kakao_id, user_info)
link_kakao_identity(user_id, kakao_id, user_info, token_data)
# "고객" 이름이면 카카오 실명으로 업데이트
if phone_user['nickname'] == '고객' and kakao_name and kakao_name != '고객':
cursor.execute("UPDATE users SET nickname = ? WHERE id = ?",
@@ -870,9 +945,17 @@ def _handle_mypage_kakao_callback(code, kakao_client):
else:
# Case C: 신규 유저 생성 + 카카오 연동
user_id, _ = get_or_create_user(kakao_phone, kakao_name)
link_kakao_identity(user_id, kakao_id, user_info)
link_kakao_identity(user_id, kakao_id, user_info, token_data)
logging.info(f"마이페이지 카카오 신규: user_id={user_id}, kakao_id={kakao_id}")
# 세션에 로그인 정보 저장 (mypage_v2용)
session['logged_in_user_id'] = user_id
session['logged_in_phone'] = kakao_phone
session['logged_in_name'] = kakao_name
# 지정된 리다이렉트 URL이 있으면 그쪽으로
if redirect_to:
return redirect(redirect_to)
return redirect(f"/my-page?phone={kakao_phone}")
@@ -904,8 +987,9 @@ def claim_kakao_callback():
return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.")
# 2.5 마이페이지 조회 목적이면 별도 처리
if state_data.get('purpose') == 'mypage':
return _handle_mypage_kakao_callback(code, get_kakao_client())
if state_data.get('purpose') in ('mypage', 'mypage_v2'):
redirect_to = '/mypage' if state_data.get('purpose') == 'mypage_v2' else None
return _handle_mypage_kakao_callback(code, get_kakao_client(), redirect_to=redirect_to)
# 3. claim 컨텍스트 복원
token_param = state_data.get('t', '')
@@ -942,6 +1026,8 @@ def claim_kakao_callback():
# 카카오에서 받은 생년월일 조합
kakao_birthday = None
kakao_bday = user_info.get('birthday') # MMDD 형식
print(f"[KAKAO DEBUG] user_info keys: {list(user_info.keys())}")
print(f"[KAKAO DEBUG] birthday={kakao_bday}, birthyear={user_info.get('birthyear')}")
if kakao_bday and len(kakao_bday) == 4:
if user_info.get('birthyear'):
kakao_birthday = f"{user_info['birthyear']}-{kakao_bday[:2]}-{kakao_bday[2:]}" # YYYY-MM-DD
@@ -963,7 +1049,7 @@ def claim_kakao_callback():
else:
user_id, is_new = get_or_create_user(kakao_phone, kakao_name, birthday=kakao_birthday)
link_kakao_identity(user_id, kakao_id, user_info)
link_kakao_identity(user_id, kakao_id, user_info, token_data)
success, msg, new_balance = claim_mileage(user_id, token_info)
if not success:
@@ -1077,6 +1163,67 @@ def mypage_kakao_start():
return redirect(auth_url)
@app.route('/mypage')
def mypage_v2():
"""확장 마이페이지 (카카오 로그인 필수)"""
user_id = session.get('logged_in_user_id')
if not user_id:
# 로그인 필요 - 카카오 로그인으로 리다이렉트
csrf_token = secrets.token_hex(16)
state_data = {'purpose': 'mypage_v2', 'csrf': csrf_token}
kakao_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
session['kakao_csrf'] = csrf_token
return render_template('my_page_login.html', kakao_state=kakao_state, redirect_to='/mypage')
# 사용자 정보 조회
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, nickname, phone, profile_image_url, mileage_balance, created_at
FROM users WHERE id = ?
""", (user_id,))
user_raw = cursor.fetchone()
if not user_raw:
session.pop('logged_in_user_id', None)
return redirect('/mypage')
user = dict(user_raw)
# 반려동물 목록 조회
cursor.execute("""
SELECT id, name, species, breed, gender, photo_url, created_at
FROM pets WHERE user_id = ? AND is_active = TRUE
ORDER BY created_at DESC
""", (user_id,))
pets = []
for row in cursor.fetchall():
species_label = '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타')
pets.append({
'id': row['id'],
'name': row['name'],
'species': row['species'],
'species_label': species_label,
'breed': row['breed'],
'gender': row['gender'],
'photo_url': row['photo_url']
})
# 구매 횟수 (적립 내역 수)
cursor.execute("SELECT COUNT(*) FROM mileage_ledger WHERE user_id = ?", (user_id,))
purchase_count = cursor.fetchone()[0]
return render_template('mypage_v2.html',
user=user,
pets=pets,
purchase_count=purchase_count)
@app.route('/my-page')
def my_page():
"""마이페이지 (전화번호로 조회)"""
@@ -4412,6 +4559,266 @@ def api_kims_interaction_check():
}), 500
# ==============================================================================
# 반려동물 API
# ==============================================================================
# 견종/묘종 데이터
DOG_BREEDS = [
'말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어',
'비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견',
'웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독',
'슈나우저', '사모예드', '허스키', '믹스견', '기타'
]
CAT_BREEDS = [
'코리안숏헤어', '페르시안', '러시안블루', '', '먼치킨', '랙돌',
'브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲',
'메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'
]
@app.route('/api/pets', methods=['GET'])
def get_pets():
"""사용자의 반려동물 목록 조회"""
# 세션에서 로그인 유저 확인
user_id = session.get('logged_in_user_id')
if not user_id:
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
try:
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, name, species, breed, gender, birth_date,
age_months, weight, photo_url, notes, created_at
FROM pets
WHERE user_id = ? AND is_active = TRUE
ORDER BY created_at DESC
""", (user_id,))
pets = []
for row in cursor.fetchall():
pets.append({
'id': row['id'],
'name': row['name'],
'species': row['species'],
'species_label': '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타'),
'breed': row['breed'],
'gender': row['gender'],
'birth_date': row['birth_date'],
'age_months': row['age_months'],
'weight': float(row['weight']) if row['weight'] else None,
'photo_url': row['photo_url'],
'notes': row['notes'],
'created_at': utc_to_kst_str(row['created_at'])
})
return jsonify({'success': True, 'pets': pets, 'count': len(pets)})
except Exception as e:
logging.error(f"반려동물 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/pets', methods=['POST'])
def create_pet():
"""반려동물 등록"""
user_id = session.get('logged_in_user_id')
if not user_id:
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
try:
data = request.get_json()
name = data.get('name', '').strip()
species = data.get('species', '').strip() # dog, cat, other
breed = data.get('breed', '').strip()
gender = data.get('gender') # male, female, unknown
if not name:
return jsonify({'success': False, 'error': '이름을 입력해주세요.'}), 400
if species not in ['dog', 'cat', 'other']:
return jsonify({'success': False, 'error': '종류를 선택해주세요.'}), 400
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO pets (user_id, name, species, breed, gender)
VALUES (?, ?, ?, ?, ?)
""", (user_id, name, species, breed, gender))
pet_id = cursor.lastrowid
conn.commit()
logging.info(f"반려동물 등록: user_id={user_id}, pet_id={pet_id}, name={name}, species={species}")
return jsonify({
'success': True,
'pet_id': pet_id,
'message': f'{name}이(가) 등록되었습니다!'
})
except Exception as e:
logging.error(f"반려동물 등록 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/pets/<int:pet_id>', methods=['PUT'])
def update_pet(pet_id):
"""반려동물 정보 수정"""
user_id = session.get('logged_in_user_id')
if not user_id:
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
try:
data = request.get_json()
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# 소유권 확인
cursor.execute("SELECT id FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id))
if not cursor.fetchone():
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
# 업데이트 필드 구성
updates = []
params = []
if 'name' in data:
updates.append("name = ?")
params.append(data['name'].strip())
if 'species' in data:
updates.append("species = ?")
params.append(data['species'])
if 'breed' in data:
updates.append("breed = ?")
params.append(data['breed'])
if 'gender' in data:
updates.append("gender = ?")
params.append(data['gender'])
if 'birth_date' in data:
updates.append("birth_date = ?")
params.append(data['birth_date'])
if 'age_months' in data:
updates.append("age_months = ?")
params.append(data['age_months'])
if 'weight' in data:
updates.append("weight = ?")
params.append(data['weight'])
if 'notes' in data:
updates.append("notes = ?")
params.append(data['notes'])
if updates:
updates.append("updated_at = CURRENT_TIMESTAMP")
params.append(pet_id)
cursor.execute(f"""
UPDATE pets SET {', '.join(updates)} WHERE id = ?
""", params)
conn.commit()
return jsonify({'success': True, 'message': '수정되었습니다.'})
except Exception as e:
logging.error(f"반려동물 수정 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/pets/<int:pet_id>', methods=['DELETE'])
def delete_pet(pet_id):
"""반려동물 삭제 (soft delete)"""
user_id = session.get('logged_in_user_id')
if not user_id:
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
try:
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# 소유권 확인 및 삭제
cursor.execute("""
UPDATE pets SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""", (pet_id, user_id))
if cursor.rowcount == 0:
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
conn.commit()
return jsonify({'success': True, 'message': '삭제되었습니다.'})
except Exception as e:
logging.error(f"반려동물 삭제 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/pets/<int:pet_id>/photo', methods=['POST'])
def upload_pet_photo(pet_id):
"""반려동물 사진 업로드"""
user_id = session.get('logged_in_user_id')
if not user_id:
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
try:
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# 소유권 확인
cursor.execute("SELECT id, name FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id))
pet = cursor.fetchone()
if not pet:
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
if 'photo' not in request.files:
return jsonify({'success': False, 'error': '사진 파일이 없습니다.'}), 400
file = request.files['photo']
if file.filename == '':
return jsonify({'success': False, 'error': '파일을 선택해주세요.'}), 400
# 파일 확장자 체크
allowed = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
if ext not in allowed:
return jsonify({'success': False, 'error': '지원하지 않는 이미지 형식입니다.'}), 400
# 파일 저장
import uuid
filename = f"pet_{pet_id}_{uuid.uuid4().hex[:8]}.{ext}"
upload_dir = Path(app.root_path) / 'static' / 'uploads' / 'pets'
upload_dir.mkdir(parents=True, exist_ok=True)
filepath = upload_dir / filename
file.save(str(filepath))
# DB 업데이트
photo_url = f"/static/uploads/pets/{filename}"
cursor.execute("""
UPDATE pets SET photo_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
""", (photo_url, pet_id))
conn.commit()
logging.info(f"반려동물 사진 업로드: pet_id={pet_id}, filename={filename}")
return jsonify({
'success': True,
'photo_url': photo_url,
'message': f'{pet["name"]} 사진이 등록되었습니다!'
})
except Exception as e:
logging.error(f"반려동물 사진 업로드 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/pets/breeds/<species>')
def get_breeds(species):
"""종류별 품종 목록 조회"""
if species == 'dog':
return jsonify({'success': True, 'breeds': DOG_BREEDS})
elif species == 'cat':
return jsonify({'success': True, 'breeds': CAT_BREEDS})
else:
return jsonify({'success': True, 'breeds': ['기타']})
if __name__ == '__main__':
import os