diff --git a/backend/app.py b/backend/app.py index bda168c..ff61b6b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -7605,6 +7605,354 @@ def api_paai_log_detail(log_id): return jsonify({'success': False, 'error': str(e)}), 500 +# ══════════════════════════════════════════════════════════════════ +# 모바일 이미지 업로드 세션 (QR 기반) +# ══════════════════════════════════════════════════════════════════ +import uuid +from datetime import datetime, timedelta + +# 메모리 기반 세션 저장소 (서버 재시작 시 초기화됨) +upload_sessions = {} + +def cleanup_expired_sessions(): + """만료된 세션 정리""" + now = datetime.now() + expired = [sid for sid, s in upload_sessions.items() if s['expires_at'] < now] + for sid in expired: + del upload_sessions[sid] + +@app.route('/api/upload-session', methods=['POST']) +def api_create_upload_session(): + """업로드 세션 생성 (QR용)""" + cleanup_expired_sessions() + + data = request.get_json() or {} + barcode = data.get('barcode', '') + + if not barcode: + return jsonify({'success': False, 'error': '바코드가 필요합니다'}), 400 + + session_id = str(uuid.uuid4())[:12] # 짧은 ID + expires_at = datetime.now() + timedelta(minutes=10) + + upload_sessions[session_id] = { + 'barcode': barcode, + 'created_at': datetime.now(), + 'expires_at': expires_at, + 'status': 'pending', # pending → uploaded + 'image_base64': None + } + + # QR URL 생성 + qr_url = f"https://mile.0bin.in/upload/{session_id}?barcode={barcode}" + + return jsonify({ + 'success': True, + 'session_id': session_id, + 'qr_url': qr_url, + 'expires_in': 600 + }) + +@app.route('/api/upload-session/') +def api_get_upload_session(session_id): + """업로드 세션 상태 확인 (폴링용)""" + cleanup_expired_sessions() + + session = upload_sessions.get(session_id) + if not session: + return jsonify({'status': 'expired'}) + + result = {'status': session['status']} + if session['status'] == 'uploaded' and session['image_base64']: + result['image_base64'] = session['image_base64'] + + return jsonify(result) + +@app.route('/api/upload-session//image', methods=['POST']) +def api_upload_session_image(session_id): + """모바일에서 이미지 업로드""" + session = upload_sessions.get(session_id) + if not session: + return jsonify({'success': False, 'error': '세션이 만료되었습니다'}), 404 + + if session['expires_at'] < datetime.now(): + del upload_sessions[session_id] + return jsonify({'success': False, 'error': '세션이 만료되었습니다'}), 404 + + # 이미지 데이터 받기 + if 'image' not in request.files: + # base64로 받은 경우 + data = request.get_json() or {} + image_base64 = data.get('image_base64') + if not image_base64: + return jsonify({'success': False, 'error': '이미지가 필요합니다'}), 400 + else: + # 파일로 받은 경우 + import base64 + file = request.files['image'] + image_data = file.read() + image_base64 = base64.b64encode(image_data).decode('utf-8') + + # product_images.db에 저장 + barcode = session['barcode'] + try: + img_db_path = Path(__file__).parent / 'db' / 'product_images.db' + conn = sqlite3.connect(str(img_db_path)) + cursor = conn.cursor() + + # 기존 이미지 확인 + cursor.execute('SELECT id FROM product_images WHERE barcode = ?', (barcode,)) + existing = cursor.fetchone() + + if existing: + cursor.execute(''' + UPDATE product_images + SET image_base64 = ?, updated_at = CURRENT_TIMESTAMP + WHERE barcode = ? + ''', (image_base64, barcode)) + else: + cursor.execute(''' + INSERT INTO product_images (barcode, image_base64, created_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (barcode, image_base64)) + + conn.commit() + conn.close() + + # 세션 상태 업데이트 + session['status'] = 'uploaded' + session['image_base64'] = image_base64 + + return jsonify({'success': True, 'message': '이미지가 저장되었습니다'}) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/upload/') +def mobile_upload_page(session_id): + """모바일 업로드 페이지""" + session = upload_sessions.get(session_id) + barcode = request.args.get('barcode', '') + + if not session: + return render_template_string(''' + + +세션 만료 + +
⏰ 세션이 만료되었습니다.

PC에서 다시 QR코드를 생성해주세요.
+ ''') + + return render_template_string(''' + + + + + + 제품 이미지 촬영 + + + +
+

📸 제품 이미지 촬영

+
바코드: {{ barcode }}
+ +
+
+
📷
+
카메라 버튼을 눌러
제품 사진을 촬영하세요
+
+
+ + + + + + +
+
+
업로드 중...
+
+ + +
+ + + + + ''', session_id=session_id, barcode=barcode) + + if __name__ == '__main__': import os diff --git a/backend/templates/admin_products.html b/backend/templates/admin_products.html index 2ec0981..b28b433 100644 --- a/backend/templates/admin_products.html +++ b/backend/templates/admin_products.html @@ -1710,6 +1710,7 @@ function closeImageModal() { stopCamera(); + stopQrPolling(); document.getElementById('imageModal').classList.remove('show'); imgModalBarcode = null; imgModalDrugCode = null; @@ -1721,6 +1722,106 @@ document.querySelectorAll('.img-tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab)); document.querySelectorAll('.img-tab-content').forEach(c => c.classList.toggle('active', c.id === 'imgTab' + tab.charAt(0).toUpperCase() + tab.slice(1))); if (tab === 'camera') startCamera(); else stopCamera(); + if (tab === 'qr') startQrSession(); else stopQrPolling(); + } + + // ═══ QR 모바일 업로드 ═══ + let qrSessionId = null; + let qrPollingInterval = null; + + async function startQrSession() { + const barcode = imgModalBarcode; + if (!barcode) return; + + document.getElementById('qrLoading').style.display = 'flex'; + document.getElementById('qrCanvas').style.display = 'none'; + document.getElementById('qrStatus').style.display = 'none'; + + try { + const res = await fetch('/api/upload-session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ barcode }) + }); + const data = await res.json(); + + if (data.success) { + qrSessionId = data.session_id; + generateQrCode(data.qr_url); + startQrPolling(); + } else { + document.getElementById('qrLoading').textContent = '❌ QR 생성 실패'; + } + } catch (err) { + document.getElementById('qrLoading').textContent = '❌ 네트워크 오류'; + } + } + + function generateQrCode(url) { + const canvas = document.getElementById('qrCanvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 180; + canvas.height = 180; + + // QR 라이브러리 없이 API 사용 + const img = new Image(); + img.onload = function() { + ctx.drawImage(img, 0, 0, 180, 180); + document.getElementById('qrLoading').style.display = 'none'; + canvas.style.display = 'block'; + }; + img.onerror = function() { + document.getElementById('qrLoading').textContent = '❌ QR 생성 실패'; + }; + img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(url); + } + + function startQrPolling() { + stopQrPolling(); + qrPollingInterval = setInterval(checkQrSession, 2000); + } + + function stopQrPolling() { + if (qrPollingInterval) { + clearInterval(qrPollingInterval); + qrPollingInterval = null; + } + qrSessionId = null; + } + + async function checkQrSession() { + if (!qrSessionId) return; + + try { + const res = await fetch('/api/upload-session/' + qrSessionId); + const data = await res.json(); + + const statusEl = document.getElementById('qrStatus'); + + if (data.status === 'uploaded') { + statusEl.style.display = 'block'; + statusEl.style.background = '#d1fae5'; + statusEl.style.color = '#065f46'; + statusEl.textContent = '✅ 이미지 업로드 완료!'; + + stopQrPolling(); + + // 1.5초 후 모달 닫고 목록 새로고침 + setTimeout(() => { + closeImageModal(); + searchProducts(); + showToast('📱 모바일 이미지 등록 완료!', 'success'); + }, 1500); + } else if (data.status === 'expired') { + statusEl.style.display = 'block'; + statusEl.style.background = '#fee2e2'; + statusEl.style.color = '#991b1b'; + statusEl.textContent = '⏰ 세션 만료 - 탭을 다시 선택하세요'; + stopQrPolling(); + } + } catch (err) { + console.error('QR 세션 확인 오류:', err); + } } async function startCamera() { @@ -1840,8 +1941,9 @@
- - + + +
@@ -1871,6 +1973,22 @@
+ +
+
+
+
+ QR 생성 중... +
+ +
+

📱 휴대폰으로 QR 스캔하여 촬영

+ +
+
+ +
+