diff --git a/backend/app.py b/backend/app.py index 8d5a8d4..7ae0edc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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/', 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/', 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//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/') +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 diff --git a/backend/db/dbsetup.py b/backend/db/dbsetup.py index 2a3e12e..ab6886f 100644 --- a/backend/db/dbsetup.py +++ b/backend/db/dbsetup.py @@ -154,11 +154,46 @@ class DatabaseManager: return self.engines[database] def get_session(self, database='PM_BASE'): - """특정 데이터베이스 세션 반환""" + """특정 데이터베이스 세션 반환 (자동 복구 포함)""" if database not in self.sessions: engine = self.get_engine(database) Session = sessionmaker(bind=engine) self.sessions[database] = Session() + else: + # 🔥 기존 세션 상태 체크 및 자동 복구 + session = self.sessions[database] + try: + # 세션이 유효한지 간단한 쿼리로 테스트 + session.execute(text("SELECT 1")) + except Exception as e: + error_msg = str(e).lower() + # 연결 끊김 또는 트랜잭션 에러 감지 + if any(keyword in error_msg for keyword in [ + 'invalid transaction', 'rollback', 'connection', + 'closed', 'lost', 'timeout', 'network', 'disconnect' + ]): + print(f"[DB Manager] {database} 세션 복구 시도: {e}") + try: + session.rollback() + print(f"[DB Manager] {database} 롤백 성공, 세션 재사용") + except Exception as rollback_err: + print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}") + try: + session.close() + except: + pass + del self.sessions[database] + # 새 세션 생성 + engine = self.get_engine(database) + Session = sessionmaker(bind=engine) + self.sessions[database] = Session() + print(f"[DB Manager] {database} 새 세션 생성 완료") + else: + # 다른 종류의 에러면 롤백만 시도 + try: + session.rollback() + except: + pass return self.sessions[database] def rollback_session(self, database='PM_BASE'): @@ -237,7 +272,13 @@ class DatabaseManager: self.init_sqlite_schema() self.sqlite_conn = old_conn print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}") - + else: + # 기존 DB: 마이그레이션 실행 + old_conn = self.sqlite_conn + self.sqlite_conn = conn + self._migrate_sqlite() + self.sqlite_conn = old_conn + return conn def init_sqlite_schema(self): @@ -319,6 +360,43 @@ class DatabaseManager: self.sqlite_conn.commit() print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성") + # customer_identities 토큰 저장 컬럼 추가 + cursor.execute("PRAGMA table_info(customer_identities)") + ci_columns = [row[1] for row in cursor.fetchall()] + if 'access_token' not in ci_columns: + cursor.execute("ALTER TABLE customer_identities ADD COLUMN access_token TEXT") + cursor.execute("ALTER TABLE customer_identities ADD COLUMN refresh_token TEXT") + cursor.execute("ALTER TABLE customer_identities ADD COLUMN token_expires_at DATETIME") + self.sqlite_conn.commit() + print("[DB Manager] SQLite 마이그레이션: customer_identities 토큰 컬럼 추가") + + # pets 테이블 생성 (반려동물) + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'") + if not cursor.fetchone(): + cursor.executescript(""" + CREATE TABLE IF NOT EXISTS pets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name VARCHAR(50) NOT NULL, + species VARCHAR(20) NOT NULL, + breed VARCHAR(50), + gender VARCHAR(10), + birth_date DATE, + age_months INTEGER, + weight DECIMAL(5,2), + photo_url TEXT, + notes TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id); + CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species); + """) + self.sqlite_conn.commit() + print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성") + def test_connection(self, database='PM_BASE'): """연결 테스트""" try: diff --git a/backend/db/mileage_schema.sql b/backend/db/mileage_schema.sql index 6430c7c..64e5174 100644 --- a/backend/db/mileage_schema.sql +++ b/backend/db/mileage_schema.sql @@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customer_identities ( provider VARCHAR(20) NOT NULL, provider_user_id VARCHAR(100) NOT NULL, provider_data TEXT, + access_token TEXT, + refresh_token TEXT, + token_expires_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), UNIQUE(provider, provider_user_id) @@ -120,3 +123,25 @@ CREATE TABLE IF NOT EXISTS ai_recommendations ( CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status); CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at); + +-- 8. 반려동물 테이블 +CREATE TABLE IF NOT EXISTS pets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name VARCHAR(50) NOT NULL, -- 이름 (예: 뽀삐, 나비) + species VARCHAR(20) NOT NULL, -- 종류: dog, cat, other + breed VARCHAR(50), -- 품종 (말티즈, 페르시안 등) + gender VARCHAR(10), -- male, female, unknown + birth_date DATE, -- 생년월일 (나중에 사용) + age_months INTEGER, -- 월령 (나중에 사용) + weight DECIMAL(5,2), -- 체중 kg (나중에 사용) + photo_url TEXT, -- 사진 URL + notes TEXT, -- 특이사항/메모 + is_active BOOLEAN DEFAULT TRUE, -- 활성 상태 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id); +CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species); diff --git a/backend/static/uploads/pets/pet_1_d4ffe983.png b/backend/static/uploads/pets/pet_1_d4ffe983.png new file mode 100644 index 0000000..09d486c Binary files /dev/null and b/backend/static/uploads/pets/pet_1_d4ffe983.png differ diff --git a/backend/templates/my_page.html b/backend/templates/my_page.html index 48ebee6..f17e35a 100644 --- a/backend/templates/my_page.html +++ b/backend/templates/my_page.html @@ -119,6 +119,49 @@ letter-spacing: -0.2px; } + /* 퀵 메뉴 */ + .quick-menu { + display: flex; + justify-content: space-around; + padding: 20px 16px; + background: #fff; + margin: 0 16px 16px; + border-radius: 16px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); + } + + .quick-menu-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-decoration: none; + padding: 8px 12px; + border-radius: 12px; + transition: background 0.2s; + } + + .quick-menu-item:active { + background: #f5f5f5; + } + + .quick-menu-icon { + width: 48px; + height: 48px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + } + + .quick-menu-item span { + font-size: 12px; + font-weight: 600; + color: #495057; + letter-spacing: -0.3px; + } + .section { padding: 24px; } @@ -301,6 +344,26 @@
약국에서 1P = 1원으로 사용 가능
+ + +
적립 내역
diff --git a/backend/templates/mypage_v2.html b/backend/templates/mypage_v2.html new file mode 100644 index 0000000..b16ec3b --- /dev/null +++ b/backend/templates/mypage_v2.html @@ -0,0 +1,891 @@ + + + + + + + + + + + + 마이페이지 - 청춘약국 + + + + + + +
+ +
+
+

마이페이지

+ 로그아웃 +
+
+ + +
+
+
+ {% if user.profile_image_url %} + 프로필 + {% else %} + 😊 + {% endif %} +
+
+

{{ user.nickname or '회원' }}님

+

{{ user.phone or '전화번호 미등록' }}

+
+
+
+
+
🎁
+
{{ '{:,}'.format(user.mileage_balance or 0) }}
+
포인트
+
+
+
📦
+
{{ purchase_count or 0 }}
+
구매
+
+
+
🐾
+
{{ pets|length }}
+
반려동물
+
+
+
+ + +
+
+

🐾 내 반려동물

+
+ +
+ {% if pets %} + {% for pet in pets %} +
+
+ {% if pet.photo_url %} + {{ pet.name }} + {% else %} + {{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }} + {% endif %} +
+
+
{{ pet.name }}
+
+ {{ pet.species_label }} + {% if pet.breed %}· {{ pet.breed }}{% endif %} + {% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %} +
+
+ +
+ {% endfor %} + {% else %} +
+
🐾
+

등록된 반려동물이 없습니다

+
+ {% endif %} +
+ + +
+ + +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/docs/ANIMAL_DRUG_APC_MAPPING.md b/docs/ANIMAL_DRUG_APC_MAPPING.md new file mode 100644 index 0000000..c25a2cb --- /dev/null +++ b/docs/ANIMAL_DRUG_APC_MAPPING.md @@ -0,0 +1,176 @@ +# 동물약 APC 매핑 가이드 + +> 최종 업데이트: 2026-03-02 + +## 개요 + +POS(PIT3000)의 동물약 제품을 APDB의 APC 코드와 매핑하여 제품 정보(용법, 용량, 주의사항) 및 이미지를 표시하기 위한 작업 가이드. + +--- + +## 현재 상태 + +### 매핑 현황 + +| 구분 | 개수 | 비율 | +|------|------|------| +| 동물약 총 | 39개 | 100% | +| APC 매핑됨 | 7개 | 18% | +| **APC 미매핑** | **32개** | **82%** | + +### 매핑 완료 제품 + +| POS 제품명 | DrugCode | APC | +|------------|----------|-----| +| (판)복합개시딘 | LB000003140 | 0231093520106 | +| 안텔민킹(5kg이상) | LB000003158 | 0230237810109 | +| 안텔민뽀삐(5kg이하) | LB000003157 | 0230237010107 | +| 파라캅L(5kg이상) | LB000003159 | 0230338510101 | +| 파라캅S(5kg이하) | LB000003160 | 0230347110106 | +| 세레니아정16mg(개멀미약) | LB000003353 | 0231884610109 | +| 세레니아정24mg(개멀미약) | LB000003354 | 0231884620107 | + +--- + +## 매핑 구조 + +### 데이터베이스 연결 + +``` +MSSQL (192.168.0.4\PM2014) PostgreSQL (192.168.0.87:5432) +┌─────────────────────────┐ ┌─────────────────────────┐ +│ PM_DRUG.CD_GOODS │ │ apdb_master.apc │ +│ - DrugCode │ │ - apc (PK) │ +│ - GoodsName │ │ - product_name │ +│ - BARCODE │ │ - image_url1 │ +│ │ │ - llm_pharm (JSONB) │ +├─────────────────────────┤ └─────────────────────────┘ +│ PM_DRUG.CD_ITEM_UNIT_ │ +│ MEMBER │ +│ - DRUGCODE (FK) │ +│ - CD_CD_BARCODE ◀───────┼── APC 코드 저장 (023%로 시작) +│ - CHANGE_DATE │ +└─────────────────────────┘ +``` + +### APC 매핑 방식 + +1. `CD_ITEM_UNIT_MEMBER` 테이블에 **추가 바코드**로 APC 등록 +2. 기존 바코드는 유지, APC를 별도 레코드로 INSERT +3. APC 코드는 `023%`로 시작 (식별자) + +--- + +## 1:1 매핑 가능 후보 + +### ✅ 확실한 매핑 (1개) + +| POS 제품명 | DrugCode | APC | APDB 제품명 | 이미지 | +|------------|----------|-----|-------------|--------| +| **제스타제(10정)** | LB000003146 | 8809720800455 | 제스타제 | ✅ 있음 | + +### ⚠️ 검토 필요 (1개) + +| POS 제품명 | DrugCode | APC 후보 | 비고 | +|------------|----------|----------|------| +| 안텔민 | S0000001 | 0230237800003 | "안텔민킹"과 "안텔민뽀삐"는 이미 별도 매핑됨. 이 제품이 무엇인지 확인 필요 | + +### ❌ APDB에 없음 (3개) + +| POS 제품명 | 사유 | +|------------|------| +| (판)클라펫정50(100정) | APDB엔 "클라펫 정"만 있음 (함량 불일치) | +| 넥스가드xs(2~3.5kg) | 사이즈별 APC 없음 | +| 캐치원캣(2.5~7.5kg)/고양이 | APDB에 캐치원 자체가 없음 | + +--- + +## 1:N 매핑 필요 제품 (27개) + +사이즈별로 세분화된 제품들. 하나의 APDB APC에 여러 POS 제품을 매핑해야 함. + +### 브랜드별 현황 + +| 브랜드 | POS 제품 수 | APDB 존재 | 비고 | +|--------|-------------|-----------|------| +| 다이로하트 | 3개 (SS/S/M) | ✅ | 다이로하트 츄어블 정 | +| 하트세이버 | 4개 (mini/S/M/L) | ✅ | 하트세이버 플러스 츄어블 | +| 하트웜솔루션 | 2개 (S/M) | ❌ | APDB에 없음 | +| 리펠로 | 2개 (S/M) | ✅ | 리펠로액 (이미지 있음!) | +| 캐치원 | 5개 (SS/S/M/L/캣) | ❌ | APDB에 없음 | +| 셀라이트 | 5개 (SS/S/M/L/XL) | ✅ | 셀라이트 액 | +| 넥스가드 | 2개 (xs/L) | ✅ | 넥스가드 스펙트라 | +| 가드닐 | 3개 (S/M/L) | ✅ | 가드닐 액 | +| 심피드 | 2개 (M/L) | ❌ | APDB에 없음 | +| 하트캅 | 1개 | ✅ | 하트캅-츄어블 정 | + +--- + +## APDB 통계 + +| 항목 | 수치 | +|------|------| +| 전체 APC | 16,326개 | +| 이미지 있음 | 73개 (0.4%) | +| LLM 정보 있음 | 81개 (0.5%) | +| 동물 관련 키워드 | ~200개 | + +⚠️ **주의:** APDB에 이미지가 거의 없음. 이미지 표시가 목적이라면 다른 소스 필요. + +--- + +## 매핑 스크립트 + +### 매핑 후보 찾기 +```bash +python backend/scripts/batch_apc_matching.py +``` + +### 1:1 매핑 가능 후보 추출 +```bash +python backend/scripts/find_1to1_candidates.py +``` + +### 매핑 실행 (수동) +```python +# backend/scripts/batch_insert_apc.py 참고 +MAPPINGS = [ + ('제스타제(10정)', 'LB000003146', '8809720800455'), +] +``` + +### INSERT 쿼리 예시 +```sql +INSERT INTO CD_ITEM_UNIT_MEMBER ( + DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT, + CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE +) VALUES ( + 'LB000003146', -- DrugCode + '015', -- 단위코드 + 1.0, -- 단위명 + <기존값>, -- CD_MY_UNIT (기존 레코드에서 복사) + <기존값>, -- CD_IN_UNIT (기존 레코드에서 복사) + '8809720800455', -- APC 바코드 + '', + '20260302' -- 변경일자 +) +``` + +--- + +## 다음 단계 + +1. **제스타제** 1:1 매핑 실행 +2. **안텔민(S0000001)** 제품 확인 후 결정 +3. 1:N 매핑 정책 결정 (사이즈별 제품 → 동일 APC?) +4. 이미지 소스 대안 검토 (필요시) + +--- + +## 관련 파일 + +- `backend/db/dbsetup.py` - DB 연결 설정 +- `backend/scripts/batch_apc_matching.py` - 매칭 후보 찾기 +- `backend/scripts/batch_insert_apc.py` - 매핑 실행 +- `backend/scripts/find_1to1_candidates.py` - 1:1 후보 추출 +- `backend/app.py` - `_get_animal_drugs()`, `_get_animal_drug_rag()`