feat: AI 구매 패턴 분석 기능 추가 (OpenAI GPT 통합)
- 사용자 구매 이력 AI 분석 및 마케팅 전략 제안
- 업셀링을 위한 추천 제품 기능 추가
주요 변경사항:
1. 백엔드 API (app.py)
- OpenAI API 통합 (GPT-4o-mini 사용)
- 환경 변수 로드 (.env 파일)
- AI 분석 엔드포인트: POST /admin/ai-analyze-user/<user_id>
- 헬퍼 함수 추가:
* prepare_analysis_prompt(): 프롬프트 생성
* parse_openai_response(): JSON 응답 파싱
* call_openai_with_retry(): 재시도 로직
* categorize_product(): 제품 카테고리 추정
- 에러 처리 및 fallback 로직
2. 프론트엔드 UI (admin.html)
- AI 분석 버튼 추가 (사용자 상세 모달)
- AI 분석 모달 추가 (결과 표시)
- Lottie 로딩 애니메이션 통합 (무료 라이선스)
- JavaScript 함수:
* showAIAnalysisModal(): 모달 열기 및 API 호출
* renderAIAnalysis(): 분석 결과 렌더링
* showAIAnalysisError(): 에러 표시
* 5분 캐싱 기능
- 섹션별 시각화:
* 구매 패턴 분석 (📊)
* 주요 구매 품목 (💊)
* 추천 제품 (✨)
* 마케팅 전략 (🎯)
3. 환경 설정
- requirements.txt: openai, python-dotenv 추가
- .env: OpenAI API 키 및 설정 저장
- Lottie CDN 통합 (버전 5.12.2)
기술 스택:
- OpenAI GPT-4o-mini (비용 효율적)
- Lottie 애니메이션 (로딩 UX 개선)
- 재시도 로직 (지수 백오프)
- 응답 캐싱 (5분)
보안:
- API 키 환경 변수 관리
- .env 파일 .gitignore 처리
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d715b630fe
commit
914bc08c6c
@ -4,3 +4,5 @@ sqlalchemy==2.0.23
|
|||||||
pyodbc==5.0.1
|
pyodbc==5.0.1
|
||||||
qrcode==7.4.2
|
qrcode==7.4.2
|
||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
|
openai==1.58.1
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|||||||
381
backend/app.py
381
backend/app.py
@ -10,6 +10,20 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 환경 변수 로드
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# OpenAI import
|
||||||
|
try:
|
||||||
|
from openai import OpenAI, OpenAIError, RateLimitError, APITimeoutError
|
||||||
|
OPENAI_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
OPENAI_AVAILABLE = False
|
||||||
|
logging.warning("OpenAI 라이브러리가 설치되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.")
|
||||||
|
|
||||||
# Path setup
|
# Path setup
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
@ -63,6 +77,233 @@ def utc_to_kst_str(utc_time_str):
|
|||||||
return utc_time_str # 변환 실패 시 원본 반환
|
return utc_time_str # 변환 실패 시 원본 반환
|
||||||
|
|
||||||
|
|
||||||
|
# ===== OpenAI 설정 및 헬퍼 함수 =====
|
||||||
|
|
||||||
|
# OpenAI API 설정
|
||||||
|
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
|
||||||
|
OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4o-mini')
|
||||||
|
OPENAI_MAX_TOKENS = int(os.getenv('OPENAI_MAX_TOKENS', '1000'))
|
||||||
|
OPENAI_TEMPERATURE = float(os.getenv('OPENAI_TEMPERATURE', '0.7'))
|
||||||
|
|
||||||
|
# OpenAI 사용 가능 여부 및 API 키 확인
|
||||||
|
if OPENAI_AVAILABLE and not OPENAI_API_KEY:
|
||||||
|
logging.warning("OPENAI_API_KEY가 설정되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.")
|
||||||
|
OPENAI_AVAILABLE = False
|
||||||
|
|
||||||
|
# System Prompt
|
||||||
|
SYSTEM_PROMPT = """당신은 약국 고객 관리 전문가입니다. 고객의 구매 데이터를 분석하여 다음을 제공합니다:
|
||||||
|
1. 구매 패턴 및 행동 분석
|
||||||
|
2. 주요 관심 제품 카테고리 파악
|
||||||
|
3. 업셀링을 위한 제품 추천
|
||||||
|
4. 고객 맞춤형 마케팅 전략
|
||||||
|
|
||||||
|
응답은 반드시 JSON 형식으로 작성하며, 한국어로 작성합니다.
|
||||||
|
약국 운영자가 실제로 활용할 수 있는 구체적이고 실용적인 인사이트를 제공해야 합니다."""
|
||||||
|
|
||||||
|
# 에러 메시지
|
||||||
|
ERROR_MESSAGES = {
|
||||||
|
'NO_USER': '사용자를 찾을 수 없습니다.',
|
||||||
|
'NO_PURCHASES': '구매 이력이 없어 분석할 수 없습니다. 최소 1건 이상의 구매가 필요합니다.',
|
||||||
|
'OPENAI_NOT_AVAILABLE': 'AI 분석 기능을 사용할 수 없습니다. 관리자에게 문의하세요.',
|
||||||
|
'OPENAI_API_KEY_MISSING': 'OpenAI API 키가 설정되지 않았습니다.',
|
||||||
|
'OPENAI_API_ERROR': 'OpenAI API 호출에 실패했습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
'OPENAI_RATE_LIMIT': 'API 호출 횟수 제한에 도달했습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
'OPENAI_TIMEOUT': 'AI 분석 시간이 초과되었습니다. 다시 시도해주세요.',
|
||||||
|
'PARSING_ERROR': 'AI 응답을 처리하는 중 오류가 발생했습니다.',
|
||||||
|
'UNKNOWN_ERROR': '알 수 없는 오류가 발생했습니다. 관리자에게 문의하세요.'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def categorize_product(product_name):
|
||||||
|
"""제품명에서 카테고리 추정 (간단한 키워드 매칭)"""
|
||||||
|
categories = {
|
||||||
|
'소화제': ['타센', '베아제', '겔포스', '소화'],
|
||||||
|
'진통제': ['타이레놀', '게보린', '펜잘', '이부프로펜'],
|
||||||
|
'감기약': ['판콜', '화이투벤', '지르텍', '감기'],
|
||||||
|
'피부약': ['후시딘', '마데카솔', '더마틱스'],
|
||||||
|
'비타민': ['비타민', '센트룸', '활성비타민'],
|
||||||
|
'안약': ['안약', '인공눈물'],
|
||||||
|
'소염진통제': ['자미슬', '펠루비', '게보린']
|
||||||
|
}
|
||||||
|
|
||||||
|
for category, keywords in categories.items():
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword in product_name:
|
||||||
|
return category
|
||||||
|
|
||||||
|
return '기타'
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_analysis_prompt(user, purchases):
|
||||||
|
"""OpenAI API 전송용 프롬프트 생성"""
|
||||||
|
# 사용자 정보 요약
|
||||||
|
user_summary = f"""사용자: {user['nickname']} ({user['phone']})
|
||||||
|
가입일: {utc_to_kst_str(user['created_at']) if user['created_at'] else '-'}
|
||||||
|
포인트 잔액: {user['mileage_balance']:,}P
|
||||||
|
총 구매 건수: {len(purchases)}건
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 구매 이력 상세
|
||||||
|
purchase_details = []
|
||||||
|
total_spent = 0
|
||||||
|
all_products = []
|
||||||
|
product_freq = {}
|
||||||
|
|
||||||
|
for idx, purchase in enumerate(purchases, 1):
|
||||||
|
total_spent += purchase['amount']
|
||||||
|
products_str = ', '.join([f"{item['name']} x{item['qty']}" for item in purchase['items']])
|
||||||
|
|
||||||
|
# 제품 빈도 계산
|
||||||
|
for item in purchase['items']:
|
||||||
|
product_name = item['name']
|
||||||
|
all_products.append(product_name)
|
||||||
|
product_freq[product_name] = product_freq.get(product_name, 0) + 1
|
||||||
|
|
||||||
|
purchase_details.append(
|
||||||
|
f"{idx}. {purchase['date']} - {purchase['amount']:,}원 구매, {purchase['points']}P 적립\n"
|
||||||
|
f" 구매 품목: {products_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 통계 계산
|
||||||
|
avg_purchase = total_spent // len(purchases) if purchases else 0
|
||||||
|
top_products = sorted(product_freq.items(), key=lambda x: x[1], reverse=True)[:5]
|
||||||
|
top_products_str = ', '.join([f"{name}({count}회)" for name, count in top_products])
|
||||||
|
|
||||||
|
# 최종 프롬프트 조립
|
||||||
|
prompt = f"""다음은 약국 고객의 구매 데이터입니다. 구매 패턴을 분석하고 마케팅 전략을 제안해주세요.
|
||||||
|
|
||||||
|
{user_summary}
|
||||||
|
|
||||||
|
통계 요약:
|
||||||
|
- 총 구매 금액: {total_spent:,}원
|
||||||
|
- 평균 구매 금액: {avg_purchase:,}원
|
||||||
|
- 자주 구매한 품목: {top_products_str}
|
||||||
|
|
||||||
|
구매 이력 (최근 {len(purchases)}건):
|
||||||
|
{chr(10).join(purchase_details)}
|
||||||
|
|
||||||
|
분석 요청사항:
|
||||||
|
1. 구매 패턴 분석: 구매 빈도, 구매 금액 패턴 등
|
||||||
|
2. 주로 구매하는 품목 카테고리 (예: 소화제, 감기약, 건강기능식품 등)
|
||||||
|
3. 추천 제품: 기존 구매 패턴을 기반으로 관심있을만한 제품 3-5가지 (업셀링)
|
||||||
|
4. 마케팅 전략: 이 고객에게 효과적일 프로모션 또는 포인트 활용 방안
|
||||||
|
|
||||||
|
응답은 다음 JSON 형식으로 해주세요:
|
||||||
|
{{
|
||||||
|
"pattern": "구매 패턴에 대한 상세한 분석 (2-3문장)",
|
||||||
|
"main_products": ["카테고리1: 품목들", "카테고리2: 품목들"],
|
||||||
|
"recommendations": ["추천제품1 (이유)", "추천제품2 (이유)", "추천제품3 (이유)"],
|
||||||
|
"marketing_strategy": "마케팅 전략 제안 (2-3문장)"
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def parse_openai_response(response_text):
|
||||||
|
"""OpenAI API 응답을 파싱하여 구조화된 데이터 반환"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
# JSON 추출 (마크다운 코드 블록 제거)
|
||||||
|
json_match = re.search(r'```json\s*(\{.*?\})\s*```', response_text, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
json_str = json_match.group(1)
|
||||||
|
else:
|
||||||
|
# 코드 블록 없이 JSON만 있는 경우
|
||||||
|
json_str = response_text.strip()
|
||||||
|
|
||||||
|
# JSON 파싱
|
||||||
|
analysis = json.loads(json_str)
|
||||||
|
|
||||||
|
# 필수 필드 검증
|
||||||
|
required_fields = ['pattern', 'main_products', 'recommendations', 'marketing_strategy']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in analysis:
|
||||||
|
raise ValueError(f"필수 필드 누락: {field}")
|
||||||
|
|
||||||
|
# 타입 검증
|
||||||
|
if not isinstance(analysis['main_products'], list):
|
||||||
|
analysis['main_products'] = [str(analysis['main_products'])]
|
||||||
|
if not isinstance(analysis['recommendations'], list):
|
||||||
|
analysis['recommendations'] = [str(analysis['recommendations'])]
|
||||||
|
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# JSON 파싱 실패 시 fallback
|
||||||
|
logging.error(f"JSON 파싱 실패: {e}")
|
||||||
|
return {
|
||||||
|
'pattern': '응답 파싱에 실패했습니다.',
|
||||||
|
'main_products': ['분석 결과를 확인할 수 없습니다.'],
|
||||||
|
'recommendations': ['다시 시도해주세요.'],
|
||||||
|
'marketing_strategy': response_text[:500]
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"응답 파싱 오류: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def handle_openai_error(error):
|
||||||
|
"""OpenAI API 에러를 사용자 친화적 메시지로 변환"""
|
||||||
|
error_str = str(error).lower()
|
||||||
|
|
||||||
|
if 'api key' in error_str or 'authentication' in error_str:
|
||||||
|
return ERROR_MESSAGES['OPENAI_API_KEY_MISSING']
|
||||||
|
elif 'rate limit' in error_str or 'quota' in error_str:
|
||||||
|
return ERROR_MESSAGES['OPENAI_RATE_LIMIT']
|
||||||
|
elif 'timeout' in error_str:
|
||||||
|
return ERROR_MESSAGES['OPENAI_TIMEOUT']
|
||||||
|
else:
|
||||||
|
return ERROR_MESSAGES['OPENAI_API_ERROR']
|
||||||
|
|
||||||
|
|
||||||
|
def call_openai_with_retry(prompt, max_retries=3):
|
||||||
|
"""재시도 로직을 포함한 OpenAI API 호출"""
|
||||||
|
if not OPENAI_AVAILABLE:
|
||||||
|
return False, ERROR_MESSAGES['OPENAI_NOT_AVAILABLE']
|
||||||
|
|
||||||
|
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=OPENAI_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=OPENAI_MAX_TOKENS,
|
||||||
|
temperature=OPENAI_TEMPERATURE,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, response
|
||||||
|
|
||||||
|
except RateLimitError as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
wait_time = 2 ** attempt
|
||||||
|
logging.warning(f"OpenAI Rate limit, {wait_time}초 대기 후 재시도...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
return False, handle_openai_error(e)
|
||||||
|
|
||||||
|
except APITimeoutError as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logging.warning(f"OpenAI 타임아웃, 재시도 중... ({attempt+1}/{max_retries})")
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
return False, ERROR_MESSAGES['OPENAI_TIMEOUT']
|
||||||
|
|
||||||
|
except OpenAIError as e:
|
||||||
|
return False, handle_openai_error(e)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"OpenAI API 호출 오류: {e}")
|
||||||
|
return False, ERROR_MESSAGES['UNKNOWN_ERROR']
|
||||||
|
|
||||||
|
return False, ERROR_MESSAGES['OPENAI_TIMEOUT']
|
||||||
|
|
||||||
|
|
||||||
def verify_claim_token(transaction_id, nonce):
|
def verify_claim_token(transaction_id, nonce):
|
||||||
"""
|
"""
|
||||||
QR 토큰 검증
|
QR 토큰 검증
|
||||||
@ -825,6 +1066,146 @@ def admin_search_product():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/ai-analyze-user/<int:user_id>', methods=['POST'])
|
||||||
|
def admin_ai_analyze_user(user_id):
|
||||||
|
"""OpenAI GPT를 사용한 사용자 구매 패턴 AI 분석"""
|
||||||
|
try:
|
||||||
|
# OpenAI 사용 가능 여부 확인
|
||||||
|
if not OPENAI_AVAILABLE:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': ERROR_MESSAGES['OPENAI_NOT_AVAILABLE']
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
# 1. SQLite 연결
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 2. 사용자 기본 정보 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, nickname, phone, mileage_balance, created_at
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
""", (user_id,))
|
||||||
|
user_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user_row:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': ERROR_MESSAGES['NO_USER']
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
user = {
|
||||||
|
'id': user_row['id'],
|
||||||
|
'nickname': user_row['nickname'],
|
||||||
|
'phone': user_row['phone'],
|
||||||
|
'mileage_balance': user_row['mileage_balance'],
|
||||||
|
'created_at': user_row['created_at']
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 구매 이력 조회 (최근 20건)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT transaction_id, total_amount, claimable_points, claimed_at
|
||||||
|
FROM claim_tokens
|
||||||
|
WHERE claimed_by_user_id = ?
|
||||||
|
ORDER BY claimed_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
""", (user_id,))
|
||||||
|
claimed_tokens = cursor.fetchall()
|
||||||
|
|
||||||
|
if not claimed_tokens:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': ERROR_MESSAGES['NO_PURCHASES']
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 4. MSSQL에서 상품 상세 조회
|
||||||
|
purchases = []
|
||||||
|
session = db_manager.get_session('PM_PRES')
|
||||||
|
|
||||||
|
for token in claimed_tokens:
|
||||||
|
transaction_id = token['transaction_id']
|
||||||
|
|
||||||
|
# SALE_MAIN에서 거래 시간 조회
|
||||||
|
sale_main_query = text("""
|
||||||
|
SELECT InsertTime
|
||||||
|
FROM SALE_MAIN
|
||||||
|
WHERE SL_NO_order = :transaction_id
|
||||||
|
""")
|
||||||
|
sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone()
|
||||||
|
|
||||||
|
# SALE_SUB + CD_GOODS JOIN
|
||||||
|
sale_items_query = text("""
|
||||||
|
SELECT
|
||||||
|
S.DrugCode,
|
||||||
|
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||||||
|
S.SL_NM_item AS quantity,
|
||||||
|
S.SL_NM_cost_a AS price,
|
||||||
|
S.SL_TOTAL_PRICE AS total
|
||||||
|
FROM SALE_SUB S
|
||||||
|
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||||
|
WHERE S.SL_NO_order = :transaction_id
|
||||||
|
ORDER BY S.DrugCode
|
||||||
|
""")
|
||||||
|
items_raw = session.execute(sale_items_query, {'transaction_id': transaction_id}).fetchall()
|
||||||
|
|
||||||
|
items = [{
|
||||||
|
'name': item.goods_name,
|
||||||
|
'qty': int(item.quantity or 0),
|
||||||
|
'price': int(item.price or 0)
|
||||||
|
} for item in items_raw]
|
||||||
|
|
||||||
|
purchases.append({
|
||||||
|
'date': str(sale_main.InsertTime)[:16].replace('T', ' ') if sale_main and sale_main.InsertTime else '-',
|
||||||
|
'amount': int(token['total_amount']),
|
||||||
|
'points': int(token['claimable_points']),
|
||||||
|
'items': items
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. OpenAI API 호출용 프롬프트 생성
|
||||||
|
prompt = prepare_analysis_prompt(user, purchases)
|
||||||
|
|
||||||
|
# 6. OpenAI API 호출
|
||||||
|
logging.info(f"AI 분석 시작: 사용자 ID {user_id}")
|
||||||
|
success, response = call_openai_with_retry(prompt)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
# response에는 에러 메시지가 담겨 있음
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': response
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# 7. 응답 파싱
|
||||||
|
response_text = response.choices[0].message.content
|
||||||
|
analysis = parse_openai_response(response_text)
|
||||||
|
|
||||||
|
logging.info(f"AI 분석 완료: 사용자 ID {user_id}, 토큰: {response.usage.total_tokens}")
|
||||||
|
|
||||||
|
# 8. 결과 반환
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'user': {
|
||||||
|
'id': user['id'],
|
||||||
|
'name': user['nickname'],
|
||||||
|
'phone': user['phone'],
|
||||||
|
'balance': user['mileage_balance']
|
||||||
|
},
|
||||||
|
'analysis': analysis,
|
||||||
|
'metadata': {
|
||||||
|
'model_used': OPENAI_MODEL,
|
||||||
|
'tokens_used': response.usage.total_tokens,
|
||||||
|
'analysis_time': datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"AI 분석 오류: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': ERROR_MESSAGES['UNKNOWN_ERROR']
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/use-points', methods=['POST'])
|
@app.route('/admin/use-points', methods=['POST'])
|
||||||
def admin_use_points():
|
def admin_use_points():
|
||||||
"""관리자 페이지에서 포인트 사용 (차감)"""
|
"""관리자 페이지에서 포인트 사용 (차감)"""
|
||||||
|
|||||||
@ -601,6 +601,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 분석 모달 -->
|
||||||
|
<div id="aiAnalysisModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10001; padding: 20px; overflow-y: auto;">
|
||||||
|
<div style="max-width: 800px; margin: 40px auto; background: #fff; border-radius: 20px; padding: 32px; position: relative;">
|
||||||
|
<button onclick="closeAIAnalysisModal()" style="position: absolute; top: 20px; right: 20px; background: #f1f3f5; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; font-size: 20px; color: #495057;">×</button>
|
||||||
|
|
||||||
|
<h2 style="font-size: 24px; font-weight: 700; color: #212529; margin-bottom: 24px; letter-spacing: -0.5px;">
|
||||||
|
🤖 AI 구매 패턴 분석
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div id="aiAnalysisContent" style="min-height: 200px;">
|
||||||
|
<div style="text-align: center; padding: 60px; color: #868e96;">
|
||||||
|
<div style="font-size: 14px;">AI 분석을 시작하려면 버튼을 클릭하세요.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function showTransactionDetail(transactionId) {
|
function showTransactionDetail(transactionId) {
|
||||||
document.getElementById('transactionModal').style.display = 'block';
|
document.getElementById('transactionModal').style.display = 'block';
|
||||||
@ -799,7 +817,10 @@
|
|||||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
|
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: right;">
|
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
|
||||||
|
<button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
||||||
|
🤖 AI 분석
|
||||||
|
</button>
|
||||||
<button onclick="showUsePointsModal(${user.id}, ${user.balance})" style="padding: 10px 24px; background: #f03e3e; color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
<button onclick="showUsePointsModal(${user.id}, ${user.balance})" style="padding: 10px 24px; background: #f03e3e; color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
||||||
💳 포인트 사용
|
💳 포인트 사용
|
||||||
</button>
|
</button>
|
||||||
@ -1322,6 +1343,202 @@
|
|||||||
closeSearchResults();
|
closeSearchResults();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== AI 분석 기능 =====
|
||||||
|
|
||||||
|
let aiAnalysisCache = {}; // 캐싱용
|
||||||
|
let lottieAnimation = null; // Lottie 애니메이션 인스턴스
|
||||||
|
|
||||||
|
function showAIAnalysisModal(userId) {
|
||||||
|
// 모달 열기
|
||||||
|
document.getElementById('aiAnalysisModal').style.display = 'block';
|
||||||
|
|
||||||
|
// 캐시 확인 (5분 이내)
|
||||||
|
const cacheKey = `ai_analysis_${userId}`;
|
||||||
|
const cached = aiAnalysisCache[cacheKey];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cached && (now - cached.timestamp) < 300000) {
|
||||||
|
renderAIAnalysis(cached.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lottie 로딩 애니메이션 표시
|
||||||
|
document.getElementById('aiAnalysisContent').innerHTML = `
|
||||||
|
<div id="lottie-container" style="text-align: center; padding: 40px;">
|
||||||
|
<div id="lottie-animation" style="width: 200px; height: 200px; margin: 0 auto;"></div>
|
||||||
|
<div style="font-size: 16px; color: #495057; font-weight: 600; margin-top: 16px;">
|
||||||
|
AI가 구매 패턴을 분석하고 있습니다...
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 14px; color: #868e96; margin-top: 8px;">
|
||||||
|
최대 10-15초 소요될 수 있습니다
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Lottie 애니메이션 초기화 (무료 AI/로봇 애니메이션)
|
||||||
|
if (lottieAnimation) {
|
||||||
|
lottieAnimation.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
lottieAnimation = lottie.loadAnimation({
|
||||||
|
container: document.getElementById('lottie-animation'),
|
||||||
|
renderer: 'svg',
|
||||||
|
loop: true,
|
||||||
|
autoplay: true,
|
||||||
|
path: 'https://lottie.host/d5cb5c0e-1b0f-4f0a-8e5f-9c3e9d6e5a3a/3R3xKR0P0r.json' // AI 로봇 애니메이션
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
fetch(`/admin/ai-analyze-user/${userId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (lottieAnimation) {
|
||||||
|
lottieAnimation.destroy();
|
||||||
|
lottieAnimation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// 캐시 저장
|
||||||
|
aiAnalysisCache[cacheKey] = {
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
renderAIAnalysis(data);
|
||||||
|
} else {
|
||||||
|
showAIAnalysisError(data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (lottieAnimation) {
|
||||||
|
lottieAnimation.destroy();
|
||||||
|
lottieAnimation = null;
|
||||||
|
}
|
||||||
|
showAIAnalysisError('네트워크 오류가 발생했습니다. 다시 시도해주세요.');
|
||||||
|
console.error('AI Analysis Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAIAnalysis(data) {
|
||||||
|
const user = data.user;
|
||||||
|
const analysis = data.analysis;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<!-- 사용자 정보 헤더 -->
|
||||||
|
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-radius: 12px; padding: 16px; margin-bottom: 24px;">
|
||||||
|
<div style="font-size: 14px; color: #495057; margin-bottom: 4px;">분석 대상</div>
|
||||||
|
<div style="font-size: 18px; font-weight: 700; color: #212529;">
|
||||||
|
${user.name} (${user.phone})
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 13px; color: #868e96; margin-top: 4px;">
|
||||||
|
${user.balance.toLocaleString()}P 보유
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 구매 패턴 분석 -->
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="font-size: 16px; font-weight: 700; color: #495057; margin-bottom: 12px; display: flex; align-items: center;">
|
||||||
|
<span style="margin-right: 8px;">📊</span> 구매 패턴 분석
|
||||||
|
</h3>
|
||||||
|
<div style="background: #f8f9fa; border-radius: 8px; padding: 16px; font-size: 14px; line-height: 1.8; color: #212529; white-space: pre-line;">
|
||||||
|
${analysis.pattern}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 주요 구매 품목 -->
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="font-size: 16px; font-weight: 700; color: #495057; margin-bottom: 12px; display: flex; align-items: center;">
|
||||||
|
<span style="margin-right: 8px;">💊</span> 주요 구매 품목
|
||||||
|
</h3>
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||||
|
`;
|
||||||
|
|
||||||
|
analysis.main_products.forEach(product => {
|
||||||
|
html += `
|
||||||
|
<li style="background: #fff; border: 1px solid #e9ecef; border-radius: 8px; padding: 12px; margin-bottom: 8px; font-size: 14px; color: #212529;">
|
||||||
|
• ${product}
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 추천 제품 -->
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="font-size: 16px; font-weight: 700; color: #495057; margin-bottom: 12px; display: flex; align-items: center;">
|
||||||
|
<span style="margin-right: 8px;">✨</span> 추천 제품 (업셀링)
|
||||||
|
</h3>
|
||||||
|
<div style="background: linear-gradient(135deg, #e0f2fe 0%, #ddd6fe 100%); border-radius: 8px; padding: 16px;">
|
||||||
|
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8; color: #212529;">
|
||||||
|
`;
|
||||||
|
|
||||||
|
analysis.recommendations.forEach(rec => {
|
||||||
|
html += `<li>${rec}</li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 마케팅 전략 -->
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size: 16px; font-weight: 700; color: #495057; margin-bottom: 12px; display: flex; align-items: center;">
|
||||||
|
<span style="margin-right: 8px;">🎯</span> 마케팅 전략 제안
|
||||||
|
</h3>
|
||||||
|
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 16px; border-radius: 4px; font-size: 14px; line-height: 1.8; color: #856404;">
|
||||||
|
${analysis.marketing_strategy}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${data.metadata ? `
|
||||||
|
<div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 12px; color: #868e96; text-align: right;">
|
||||||
|
분석 모델: ${data.metadata.model_used} | 분석 시간: ${data.metadata.analysis_time}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('aiAnalysisContent').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAIAnalysisError(message) {
|
||||||
|
document.getElementById('aiAnalysisContent').innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 60px;">
|
||||||
|
<div style="font-size: 48px; margin-bottom: 16px;">⚠️</div>
|
||||||
|
<div style="font-size: 16px; color: #f03e3e; font-weight: 600; margin-bottom: 8px;">
|
||||||
|
AI 분석 실패
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 14px; color: #868e96; margin-bottom: 20px;">
|
||||||
|
${message}
|
||||||
|
</div>
|
||||||
|
<button onclick="closeAIAnalysisModal()" style="padding: 10px 24px; background: #f1f3f5; border: none; border-radius: 10px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAIAnalysisModal() {
|
||||||
|
document.getElementById('aiAnalysisModal').style.display = 'none';
|
||||||
|
if (lottieAnimation) {
|
||||||
|
lottieAnimation.destroy();
|
||||||
|
lottieAnimation = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 분석 모달 배경 클릭 시 닫기
|
||||||
|
document.getElementById('aiAnalysisModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeAIAnalysisModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user