feat: 키오스크 마일리지 적립 시스템 추가
- 키오스크 전체화면 웹 UI (/kiosk) - QR 표시 + 전화번호 숫자패드 입력 - 키오스크 API 4개 (trigger, current, claim, kiosk 페이지) - POS GUI에 "키오스크 적립" 버튼 추가 (Flask 서버로 HTTP 트리거) - NHN Cloud 알림톡 발송 모듈 (적립 완료 시 자동 발송) - Qt 플랫폼 플러그인 경로 자동 설정 (no Qt platform plugin 에러 해결) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
205
backend/app.py
205
backend/app.py
@@ -45,6 +45,9 @@ db_manager = DatabaseManager()
|
||||
# KST 타임존 (UTC+9)
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
# 키오스크 현재 세션 (메모리 변수, 서버 재시작 시 초기화)
|
||||
kiosk_current_session = None
|
||||
|
||||
|
||||
def utc_to_kst_str(utc_time_str):
|
||||
"""
|
||||
@@ -1846,6 +1849,208 @@ def admin():
|
||||
recent_tokens=recent_tokens)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 키오스크 적립
|
||||
# ============================================================================
|
||||
|
||||
@app.route('/kiosk')
|
||||
def kiosk():
|
||||
"""키오스크 메인 페이지 (전체 화면 웹 UI)"""
|
||||
return render_template('kiosk.html')
|
||||
|
||||
|
||||
@app.route('/api/kiosk/trigger', methods=['POST'])
|
||||
def api_kiosk_trigger():
|
||||
"""
|
||||
POS → 키오스크 세션 생성
|
||||
POST /api/kiosk/trigger
|
||||
Body: {"transaction_id": "...", "amount": 50000}
|
||||
"""
|
||||
global kiosk_current_session
|
||||
|
||||
data = request.get_json()
|
||||
transaction_id = data.get('transaction_id')
|
||||
amount = data.get('amount', 0)
|
||||
|
||||
if not transaction_id:
|
||||
return jsonify({'success': False, 'message': 'transaction_id가 필요합니다.'}), 400
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# QR 토큰 존재 확인
|
||||
cursor.execute("SELECT id, token_hash, claimable_points, claimed_at FROM claim_tokens WHERE transaction_id = ?",
|
||||
(transaction_id,))
|
||||
token_row = cursor.fetchone()
|
||||
|
||||
if token_row and token_row['claimed_at']:
|
||||
return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400
|
||||
|
||||
if token_row:
|
||||
# 기존 토큰 사용
|
||||
claimable_points = token_row['claimable_points']
|
||||
# nonce 재생성 (기존 토큰의 hash에서 nonce를 알 수 없으므로 QR URL 재생성)
|
||||
from utils.qr_token_generator import QR_BASE_URL
|
||||
# 기존 토큰의 nonce를 DB에서 가져올 수 없으므로, 새 nonce로 QR 재생성은 불가
|
||||
# → 키오스크에서는 QR 대신 transaction_id + nonce를 직접 제공
|
||||
qr_url = None
|
||||
else:
|
||||
# 새 토큰 생성
|
||||
from utils.qr_token_generator import generate_claim_token, save_token_to_db
|
||||
token_info = generate_claim_token(transaction_id, float(amount))
|
||||
success, error = save_token_to_db(
|
||||
transaction_id,
|
||||
token_info['token_hash'],
|
||||
float(amount),
|
||||
token_info['claimable_points'],
|
||||
token_info['expires_at'],
|
||||
token_info['pharmacy_id']
|
||||
)
|
||||
if not success:
|
||||
return jsonify({'success': False, 'message': error}), 500
|
||||
|
||||
claimable_points = token_info['claimable_points']
|
||||
qr_url = token_info['qr_url']
|
||||
|
||||
# 키오스크 세션 저장
|
||||
kiosk_current_session = {
|
||||
'transaction_id': transaction_id,
|
||||
'amount': int(amount),
|
||||
'points': claimable_points,
|
||||
'qr_url': qr_url,
|
||||
'created_at': datetime.now(KST).isoformat()
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'키오스크 적립 대기 ({claimable_points}P)',
|
||||
'points': claimable_points
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"키오스크 트리거 오류: {e}")
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
|
||||
|
||||
|
||||
@app.route('/api/kiosk/current')
|
||||
def api_kiosk_current():
|
||||
"""
|
||||
키오스크 폴링 - 현재 세션 조회
|
||||
GET /api/kiosk/current
|
||||
"""
|
||||
global kiosk_current_session
|
||||
|
||||
if kiosk_current_session is None:
|
||||
return jsonify({'active': False})
|
||||
|
||||
# 5분 경과 시 자동 만료
|
||||
created = datetime.fromisoformat(kiosk_current_session['created_at'])
|
||||
if datetime.now(KST) - created > timedelta(minutes=5):
|
||||
kiosk_current_session = None
|
||||
return jsonify({'active': False})
|
||||
|
||||
return jsonify({
|
||||
'active': True,
|
||||
'transaction_id': kiosk_current_session['transaction_id'],
|
||||
'amount': kiosk_current_session['amount'],
|
||||
'points': kiosk_current_session['points'],
|
||||
'qr_url': kiosk_current_session.get('qr_url')
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/kiosk/claim', methods=['POST'])
|
||||
def api_kiosk_claim():
|
||||
"""
|
||||
키오스크 전화번호 적립
|
||||
POST /api/kiosk/claim
|
||||
Body: {"phone": "01012345678"}
|
||||
"""
|
||||
global kiosk_current_session
|
||||
|
||||
if kiosk_current_session is None:
|
||||
return jsonify({'success': False, 'message': '적립 대기 중인 거래가 없습니다.'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
phone = data.get('phone', '').strip().replace('-', '').replace(' ', '')
|
||||
|
||||
if len(phone) < 10:
|
||||
return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400
|
||||
|
||||
transaction_id = kiosk_current_session['transaction_id']
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# claim_tokens에서 nonce 조회를 위해 token_hash로 검증
|
||||
cursor.execute("""
|
||||
SELECT id, transaction_id, token_hash, total_amount, claimable_points,
|
||||
pharmacy_id, expires_at, claimed_at, claimed_by_user_id
|
||||
FROM claim_tokens WHERE transaction_id = ?
|
||||
""", (transaction_id,))
|
||||
token_row = cursor.fetchone()
|
||||
|
||||
if not token_row:
|
||||
return jsonify({'success': False, 'message': '토큰을 찾을 수 없습니다.'}), 400
|
||||
|
||||
if token_row['claimed_at']:
|
||||
kiosk_current_session = None
|
||||
return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400
|
||||
|
||||
# 만료 확인
|
||||
expires_at = datetime.strptime(token_row['expires_at'], '%Y-%m-%d %H:%M:%S')
|
||||
if datetime.now() > expires_at:
|
||||
kiosk_current_session = None
|
||||
return jsonify({'success': False, 'message': '만료된 거래입니다.'}), 400
|
||||
|
||||
# token_info 딕셔너리 구성 (claim_mileage 호환)
|
||||
token_info = {
|
||||
'id': token_row['id'],
|
||||
'transaction_id': token_row['transaction_id'],
|
||||
'total_amount': token_row['total_amount'],
|
||||
'claimable_points': token_row['claimable_points']
|
||||
}
|
||||
|
||||
# 사용자 조회/생성
|
||||
user_id, is_new = get_or_create_user(phone, '고객')
|
||||
|
||||
# 마일리지 적립
|
||||
claim_success, claim_msg, new_balance = claim_mileage(user_id, token_info)
|
||||
|
||||
if not claim_success:
|
||||
return jsonify({'success': False, 'message': claim_msg}), 500
|
||||
|
||||
# 키오스크 세션 클리어
|
||||
claimed_points = token_info['claimable_points']
|
||||
kiosk_current_session = None
|
||||
|
||||
# 알림톡 발송 (fire-and-forget)
|
||||
try:
|
||||
from services.nhn_alimtalk import send_mileage_claim_alimtalk
|
||||
|
||||
# 유저 이름 조회 (기존 유저는 실명이 있을 수 있음)
|
||||
cursor.execute("SELECT nickname FROM users WHERE id = ?", (user_id,))
|
||||
user_row = cursor.fetchone()
|
||||
user_name = user_row['nickname'] if user_row else '고객'
|
||||
|
||||
send_mileage_claim_alimtalk(phone, user_name, claimed_points, new_balance)
|
||||
except Exception as alimtalk_err:
|
||||
logging.warning(f"알림톡 발송 실패 (적립은 완료): {alimtalk_err}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{claimed_points}P 적립 완료!',
|
||||
'points': claimed_points,
|
||||
'balance': new_balance,
|
||||
'is_new_user': is_new
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"키오스크 적립 오류: {e}")
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 개발 모드로 실행
|
||||
app.run(host='0.0.0.0', port=7001, debug=True)
|
||||
|
||||
Reference in New Issue
Block a user