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:
thug0bin 2026-03-08 13:40:12 +09:00
parent a7bcf46aaa
commit 4614fc4c0d
2 changed files with 468 additions and 2 deletions

View File

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

View File

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