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:
thug0bin
2026-02-25 13:08:02 +09:00
parent a30374cd4a
commit f80c19567a
4 changed files with 1003 additions and 0 deletions

View File

@@ -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)