feat(products): 모바일 이미지 업로드 QR 시스템 추가
- API 3개 추가:
- POST /api/upload-session (세션 생성)
- GET /api/upload-session/{id} (상태 확인/폴링)
- POST /api/upload-session/{id}/image (이미지 업로드)
- 모바일 업로드 페이지 (/upload/{session_id})
- 이미지 등록 모달에 '📱 모바일' 탭 추가
- QR 스캔 → 모바일 촬영 → PC 실시간 반영
- 2초 폴링으로 업로드 완료 감지
- 세션 10분 만료, 메모리 기반 관리
- Edit 툴로 부분 수정하여 인코딩 유지
This commit is contained in:
parent
a7bcf46aaa
commit
4614fc4c0d
348
backend/app.py
348
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/<session_id>')
|
||||
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/<session_id>/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/<session_id>')
|
||||
def mobile_upload_page(session_id):
|
||||
"""모바일 업로드 페이지"""
|
||||
session = upload_sessions.get(session_id)
|
||||
barcode = request.args.get('barcode', '')
|
||||
|
||||
if not session:
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>세션 만료</title>
|
||||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8d7da;color:#721c24;text-align:center;padding:20px;}
|
||||
.msg{font-size:18px;}</style></head>
|
||||
<body><div class="msg">⏰ 세션이 만료되었습니다.<br><br>PC에서 다시 QR코드를 생성해주세요.</div></body></html>
|
||||
''')
|
||||
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||
<title>제품 이미지 촬영</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.barcode {
|
||||
background: #f0f0f0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: #666;
|
||||
}
|
||||
.preview-area {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
border: 2px dashed #ddd;
|
||||
}
|
||||
.preview-area.has-image { border: none; }
|
||||
.preview-area img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.placeholder {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.placeholder .icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.btn:active { transform: scale(0.98); }
|
||||
.btn-camera {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-upload {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-upload:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
input[type="file"] { display: none; }
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status.success { background: #d1fae5; color: #065f46; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; }
|
||||
.loading { display: none; text-align: center; padding: 20px; }
|
||||
.loading.show { display: block; }
|
||||
.spinner {
|
||||
width: 40px; height: 40px;
|
||||
border: 4px solid #eee;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📸 제품 이미지 촬영</h1>
|
||||
<div class="barcode">바코드: {{ barcode }}</div>
|
||||
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="placeholder">
|
||||
<div class="icon">📷</div>
|
||||
<div>카메라 버튼을 눌러<br>제품 사진을 촬영하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" id="fileInput" accept="image/*" capture="environment">
|
||||
<button class="btn btn-camera" onclick="document.getElementById('fileInput').click()">
|
||||
📷 카메라로 촬영
|
||||
</button>
|
||||
|
||||
<button class="btn btn-upload" id="uploadBtn" disabled onclick="uploadImage()">
|
||||
⬆️ 업로드
|
||||
</button>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>업로드 중...</div>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const sessionId = '{{ session_id }}';
|
||||
let imageData = null;
|
||||
|
||||
document.getElementById('fileInput').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
imageData = ev.target.result;
|
||||
|
||||
const preview = document.getElementById('previewArea');
|
||||
preview.innerHTML = '<img src="' + imageData + '">';
|
||||
preview.classList.add('has-image');
|
||||
|
||||
document.getElementById('uploadBtn').disabled = false;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
async function uploadImage() {
|
||||
if (!imageData) return;
|
||||
|
||||
document.getElementById('loading').classList.add('show');
|
||||
document.getElementById('uploadBtn').disabled = true;
|
||||
|
||||
try {
|
||||
// base64에서 데이터 부분만 추출
|
||||
const base64Data = imageData.split(',')[1];
|
||||
|
||||
const res = await fetch('/api/upload-session/' + sessionId + '/image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_base64: base64Data })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('loading').classList.remove('show');
|
||||
|
||||
const status = document.getElementById('status');
|
||||
if (data.success) {
|
||||
status.className = 'status success';
|
||||
status.innerHTML = '✅ 업로드 완료!<br><br>PC에서 확인하세요.';
|
||||
status.style.display = 'block';
|
||||
|
||||
// 버튼 숨기기
|
||||
document.querySelector('.btn-camera').style.display = 'none';
|
||||
document.getElementById('uploadBtn').style.display = 'none';
|
||||
} else {
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ ' + (data.error || '업로드 실패');
|
||||
status.style.display = 'block';
|
||||
document.getElementById('uploadBtn').disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('loading').classList.remove('show');
|
||||
const status = document.getElementById('status');
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ 네트워크 오류';
|
||||
status.style.display = 'block';
|
||||
document.getElementById('uploadBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''', session_id=session_id, barcode=barcode)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
@ -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 @@
|
||||
</div>
|
||||
|
||||
<div class="image-modal-tabs">
|
||||
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
|
||||
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
|
||||
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL</button>
|
||||
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 PC촬영</button>
|
||||
<button class="img-tab-btn" data-tab="qr" onclick="switchImageTab('qr')">📱 모바일</button>
|
||||
</div>
|
||||
|
||||
<div class="img-tab-content active" id="imgTabUrl">
|
||||
@ -1871,6 +1973,22 @@
|
||||
<button class="img-modal-btn primary" onclick="submitCapturedImage()">저장하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="img-tab-content" id="imgTabQr">
|
||||
<div style="text-align:center;padding:20px 0;">
|
||||
<div id="qrContainer" style="display:inline-block;background:#fff;padding:16px;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1);">
|
||||
<div id="qrLoading" style="width:180px;height:180px;display:flex;align-items:center;justify-content:center;color:#94a3b8;">
|
||||
QR 생성 중...
|
||||
</div>
|
||||
<canvas id="qrCanvas" style="display:none;"></canvas>
|
||||
</div>
|
||||
<p style="margin-top:16px;color:#64748b;font-size:14px;">📱 휴대폰으로 QR 스캔하여 촬영</p>
|
||||
<div id="qrStatus" style="margin-top:12px;padding:8px 16px;border-radius:8px;font-size:13px;display:none;"></div>
|
||||
</div>
|
||||
<div class="img-modal-btns">
|
||||
<button class="img-modal-btn secondary" onclick="closeImageModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user