feat: 제품 이미지 카메라 촬영 기능 추가
- HTML5 getUserMedia로 카메라 촬영 지원 (모바일 후면 카메라 기본) - 1:1 가이드 박스 UI로 정사각형 크롭 안내 - 백엔드: PIL로 800x800 리사이즈 + 썸네일 생성 - 기존 URL 교체 기능과 탭 방식으로 통합 버그 수정: - closeReplaceModal() 호출 전 변수 복사로 null 전송 문제 해결 - None 값 방어 코드 추가 Docs: TROUBLESHOOTING-CAMERA-UPLOAD.md 추가
This commit is contained in:
parent
30d95c8579
commit
546a5e7ae6
@ -5927,6 +5927,101 @@ def api_replace_product_image(barcode):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/product-images/<barcode>/upload', methods=['POST'])
|
||||||
|
def api_upload_product_image(barcode):
|
||||||
|
"""카메라 촬영 이미지 업로드 (base64 -> 1:1 크롭 -> 800x800 리사이즈)"""
|
||||||
|
import sqlite3
|
||||||
|
import base64
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
image_data = (data.get('image_data') or '').strip()
|
||||||
|
product_name = data.get('product_name') or barcode
|
||||||
|
|
||||||
|
if not image_data:
|
||||||
|
return jsonify({'success': False, 'error': '이미지 데이터 필요'}), 400
|
||||||
|
|
||||||
|
# data:image/...;base64, 접두사 제거
|
||||||
|
if ',' in image_data:
|
||||||
|
image_data = image_data.split(',', 1)[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# base64 디코딩
|
||||||
|
image_bytes = base64.b64decode(image_data)
|
||||||
|
img = Image.open(BytesIO(image_bytes))
|
||||||
|
|
||||||
|
# RGBA -> RGB 변환 (PNG 등 투명 배경 처리)
|
||||||
|
if img.mode == 'RGBA':
|
||||||
|
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||||||
|
bg.paste(img, mask=img.split()[3])
|
||||||
|
img = bg
|
||||||
|
elif img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
# 1:1 중앙 크롭 (정사각형)
|
||||||
|
width, height = img.size
|
||||||
|
min_dim = min(width, height)
|
||||||
|
left = (width - min_dim) // 2
|
||||||
|
top = (height - min_dim) // 2
|
||||||
|
right = left + min_dim
|
||||||
|
bottom = top + min_dim
|
||||||
|
img = img.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
# 800x800 리사이즈
|
||||||
|
target_size = 800
|
||||||
|
img = img.resize((target_size, target_size), Image.LANCZOS)
|
||||||
|
|
||||||
|
# base64 변환 (원본)
|
||||||
|
buffer = BytesIO()
|
||||||
|
img.save(buffer, format='JPEG', quality=90)
|
||||||
|
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
# 썸네일 생성 (200x200)
|
||||||
|
thumb_size = 200
|
||||||
|
thumb_img = img.resize((thumb_size, thumb_size), Image.LANCZOS)
|
||||||
|
thumb_buffer = BytesIO()
|
||||||
|
thumb_img.save(thumb_buffer, format='JPEG', quality=85)
|
||||||
|
thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400
|
||||||
|
|
||||||
|
# SQLite 저장
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 기존 레코드 확인
|
||||||
|
cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# 기존 레코드 있으면 이미지만 업데이트
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE product_images
|
||||||
|
SET image_base64 = ?, thumbnail_base64 = ?, image_url = NULL,
|
||||||
|
status = 'manual', error_message = NULL, updated_at = datetime('now')
|
||||||
|
WHERE barcode = ?
|
||||||
|
""", (image_base64, thumbnail_base64, barcode))
|
||||||
|
else:
|
||||||
|
# 새 레코드 생성
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO product_images (barcode, product_name, image_base64, thumbnail_base64, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'manual')
|
||||||
|
""", (barcode, product_name, image_base64, thumbnail_base64))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': '촬영 이미지 저장 완료'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"이미지 업로드 오류: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/admin/product-images/stats')
|
@app.route('/api/admin/product-images/stats')
|
||||||
def api_product_images_stats():
|
def api_product_images_stats():
|
||||||
"""이미지 통계"""
|
"""이미지 통계"""
|
||||||
|
|||||||
@ -308,6 +308,30 @@
|
|||||||
to { transform: translateX(0); opacity: 1; }
|
to { transform: translateX(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 탭 스타일 */
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
@ -411,28 +435,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 이미지 교체 모달 -->
|
<!-- 이미지 교체 모달 (탭 방식: URL / 촬영) -->
|
||||||
<div class="modal" id="replaceModal">
|
<div class="modal" id="replaceModal">
|
||||||
<div class="modal-content" style="max-width: 500px;">
|
<div class="modal-content" style="max-width: 500px;">
|
||||||
<h3>🔄 이미지 교체</h3>
|
<h3>🔄 이미지 교체</h3>
|
||||||
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
|
|
||||||
구글 이미지 등에서 찾은 URL을 붙여넣으세요
|
|
||||||
</p>
|
|
||||||
<div id="replaceProductInfo" style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
<div id="replaceProductInfo" style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
||||||
<div style="font-weight: 600;" id="replaceProductName"></div>
|
<div style="font-weight: 600;" id="replaceProductName"></div>
|
||||||
<div style="font-size: 12px; color: #a855f7; font-family: monospace;" id="replaceBarcode"></div>
|
<div style="font-size: 12px; color: #a855f7; font-family: monospace;" id="replaceBarcode"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 16px;">
|
|
||||||
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
|
<!-- 탭 버튼 -->
|
||||||
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
|
<div class="replace-tabs" style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||||
placeholder="https://example.com/image.jpg">
|
<button class="tab-btn active" onclick="switchReplaceTab('url')" id="tabBtnUrl">
|
||||||
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
|
🔗 URL 입력
|
||||||
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
|
</button>
|
||||||
|
<button class="tab-btn" onclick="switchReplaceTab('camera')" id="tabBtnCamera">
|
||||||
|
📷 촬영
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL 입력 탭 -->
|
||||||
|
<div id="tabUrl" class="tab-content">
|
||||||
|
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
|
||||||
|
구글 이미지 등에서 찾은 URL을 붙여넣으세요
|
||||||
|
</p>
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
|
||||||
|
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
|
||||||
|
placeholder="https://example.com/image.jpg">
|
||||||
|
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
|
||||||
|
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: right;">
|
|
||||||
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
|
<!-- 카메라 촬영 탭 -->
|
||||||
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
|
<div id="tabCamera" class="tab-content" style="display: none;">
|
||||||
|
<!-- 카메라 뷰 -->
|
||||||
|
<div id="cameraContainer" style="position: relative; width: 100%; aspect-ratio: 1; background: #000; border-radius: 8px; overflow: hidden; margin-bottom: 12px;">
|
||||||
|
<video id="cameraVideo" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
|
||||||
|
<!-- 1:1 가이드 오버레이 -->
|
||||||
|
<div id="cameraGuide" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none;">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<!-- 반투명 외곽 -->
|
||||||
|
<rect x="0" y="0" width="100" height="100" fill="rgba(0,0,0,0.4)"/>
|
||||||
|
<!-- 중앙 투명 영역 -->
|
||||||
|
<rect x="10" y="10" width="80" height="80" fill="transparent" stroke="#a855f7" stroke-width="0.5" stroke-dasharray="2,2"/>
|
||||||
|
<!-- 모서리 강조 -->
|
||||||
|
<path d="M10,20 L10,10 L20,10" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||||
|
<path d="M80,10 L90,10 L90,20" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||||
|
<path d="M90,80 L90,90 L80,90" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||||
|
<path d="M20,90 L10,90 L10,80" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<!-- 촬영된 이미지 미리보기 -->
|
||||||
|
<canvas id="captureCanvas" style="display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #9ca3af; font-size: 12px; text-align: center; margin-bottom: 12px;">
|
||||||
|
📦 제품을 보라색 가이드 안에 맞춰주세요
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 버튼들 -->
|
||||||
|
<div id="cameraButtons" style="display: flex; gap: 8px; justify-content: center;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
|
||||||
|
<button class="btn btn-primary" id="captureBtn" onclick="capturePhoto()">📸 촬영</button>
|
||||||
|
</div>
|
||||||
|
<div id="previewButtons" style="display: none; gap: 8px; justify-content: center;">
|
||||||
|
<button class="btn btn-secondary" onclick="retakePhoto()">↩️ 다시 촬영</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitCapture()">✅ 저장</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -757,6 +833,9 @@
|
|||||||
|
|
||||||
// 이미지 교체
|
// 이미지 교체
|
||||||
let replaceTargetBarcode = null;
|
let replaceTargetBarcode = null;
|
||||||
|
let replaceTargetName = null;
|
||||||
|
let cameraStream = null;
|
||||||
|
let capturedImageData = null;
|
||||||
|
|
||||||
function openReplaceModal(barcode, productName) {
|
function openReplaceModal(barcode, productName) {
|
||||||
console.log('openReplaceModal called with:', barcode, productName);
|
console.log('openReplaceModal called with:', barcode, productName);
|
||||||
@ -767,16 +846,173 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceTargetBarcode = barcode;
|
replaceTargetBarcode = barcode;
|
||||||
document.getElementById('replaceProductName').textContent = productName || barcode;
|
replaceTargetName = productName || barcode;
|
||||||
|
document.getElementById('replaceProductName').textContent = replaceTargetName;
|
||||||
document.getElementById('replaceBarcode').textContent = barcode;
|
document.getElementById('replaceBarcode').textContent = barcode;
|
||||||
document.getElementById('replaceImageUrl').value = '';
|
document.getElementById('replaceImageUrl').value = '';
|
||||||
|
|
||||||
|
// 탭 초기화 (URL 탭으로)
|
||||||
|
switchReplaceTab('url');
|
||||||
|
|
||||||
document.getElementById('replaceModal').classList.add('show');
|
document.getElementById('replaceModal').classList.add('show');
|
||||||
document.getElementById('replaceImageUrl').focus();
|
document.getElementById('replaceImageUrl').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeReplaceModal() {
|
function closeReplaceModal() {
|
||||||
|
stopCamera();
|
||||||
document.getElementById('replaceModal').classList.remove('show');
|
document.getElementById('replaceModal').classList.remove('show');
|
||||||
replaceTargetBarcode = null;
|
replaceTargetBarcode = null;
|
||||||
|
replaceTargetName = null;
|
||||||
|
capturedImageData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchReplaceTab(tab) {
|
||||||
|
// 탭 버튼 활성화
|
||||||
|
document.getElementById('tabBtnUrl').classList.toggle('active', tab === 'url');
|
||||||
|
document.getElementById('tabBtnCamera').classList.toggle('active', tab === 'camera');
|
||||||
|
|
||||||
|
// 탭 콘텐츠 표시
|
||||||
|
document.getElementById('tabUrl').style.display = tab === 'url' ? 'block' : 'none';
|
||||||
|
document.getElementById('tabCamera').style.display = tab === 'camera' ? 'block' : 'none';
|
||||||
|
|
||||||
|
// 카메라 탭이면 카메라 시작
|
||||||
|
if (tab === 'camera') {
|
||||||
|
startCamera();
|
||||||
|
} else {
|
||||||
|
stopCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
try {
|
||||||
|
// 이전 스트림 정리
|
||||||
|
stopCamera();
|
||||||
|
|
||||||
|
// 후면 카메라 우선 (모바일)
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: { ideal: 'environment' }, // 후면 카메라
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1920 }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
};
|
||||||
|
|
||||||
|
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
video.srcObject = cameraStream;
|
||||||
|
video.style.display = 'block';
|
||||||
|
|
||||||
|
// 캡처 상태 초기화
|
||||||
|
document.getElementById('captureCanvas').style.display = 'none';
|
||||||
|
document.getElementById('cameraGuide').style.display = 'block';
|
||||||
|
document.getElementById('cameraButtons').style.display = 'flex';
|
||||||
|
document.getElementById('previewButtons').style.display = 'none';
|
||||||
|
capturedImageData = null;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('카메라 접근 오류:', err);
|
||||||
|
showToast('카메라에 접근할 수 없습니다: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (cameraStream) {
|
||||||
|
cameraStream.getTracks().forEach(track => track.stop());
|
||||||
|
cameraStream = null;
|
||||||
|
}
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturePhoto() {
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
const canvas = document.getElementById('captureCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// 비디오 크기
|
||||||
|
const vw = video.videoWidth;
|
||||||
|
const vh = video.videoHeight;
|
||||||
|
|
||||||
|
// 1:1 영역 계산 (가이드 박스: 80% 영역)
|
||||||
|
const minDim = Math.min(vw, vh);
|
||||||
|
const cropSize = minDim * 0.8;
|
||||||
|
const sx = (vw - cropSize) / 2;
|
||||||
|
const sy = (vh - cropSize) / 2;
|
||||||
|
|
||||||
|
// 캔버스를 800x800으로 설정 (최종 해상도)
|
||||||
|
canvas.width = 800;
|
||||||
|
canvas.height = 800;
|
||||||
|
|
||||||
|
// 크롭하여 그리기
|
||||||
|
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
|
||||||
|
|
||||||
|
// base64 저장
|
||||||
|
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
|
||||||
|
|
||||||
|
// UI 전환 (미리보기 모드)
|
||||||
|
video.style.display = 'none';
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
document.getElementById('cameraGuide').style.display = 'none';
|
||||||
|
document.getElementById('cameraButtons').style.display = 'none';
|
||||||
|
document.getElementById('previewButtons').style.display = 'flex';
|
||||||
|
|
||||||
|
// 카메라 스트림은 유지 (다시 촬영 위해)
|
||||||
|
}
|
||||||
|
|
||||||
|
function retakePhoto() {
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
const canvas = document.getElementById('captureCanvas');
|
||||||
|
|
||||||
|
// UI 전환 (카메라 모드)
|
||||||
|
video.style.display = 'block';
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
document.getElementById('cameraGuide').style.display = 'block';
|
||||||
|
document.getElementById('cameraButtons').style.display = 'flex';
|
||||||
|
document.getElementById('previewButtons').style.display = 'none';
|
||||||
|
capturedImageData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCapture() {
|
||||||
|
if (!capturedImageData) {
|
||||||
|
showToast('촬영된 이미지가 없습니다', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replaceTargetBarcode) {
|
||||||
|
showToast('바코드 정보가 없습니다', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 닫기 전에 값 복사 (closeReplaceModal에서 null로 리셋되므로)
|
||||||
|
const barcode = replaceTargetBarcode;
|
||||||
|
const productName = replaceTargetName;
|
||||||
|
const imageData = capturedImageData;
|
||||||
|
|
||||||
|
closeReplaceModal();
|
||||||
|
showToast(`"${productName}" 이미지 저장 중...`, 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/product-images/${barcode}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
image_data: imageData,
|
||||||
|
product_name: productName
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showToast('✅ 촬영 이미지 저장 완료!', 'success');
|
||||||
|
loadStats();
|
||||||
|
loadImages();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || '저장 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('오류: ' + err.message, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReplace() {
|
async function submitReplace() {
|
||||||
|
|||||||
74
docs/TROUBLESHOOTING-CAMERA-UPLOAD.md
Normal file
74
docs/TROUBLESHOOTING-CAMERA-UPLOAD.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# 트러블슈팅: 카메라 촬영 이미지 업로드 실패
|
||||||
|
|
||||||
|
## 📅 발생일: 2026-03-04
|
||||||
|
|
||||||
|
## 🔴 증상
|
||||||
|
- 제품 이미지 관리 페이지에서 "촬영" 기능으로 이미지 교체 시 저장 실패
|
||||||
|
- 에러 메시지: `NoneType object has no attribute 'strip'`
|
||||||
|
- API 호출은 성공하나 이미지 데이터가 `null`로 전송됨
|
||||||
|
|
||||||
|
## 🔍 원인 분석
|
||||||
|
|
||||||
|
### 1차 원인: None 값 처리 누락 (백엔드)
|
||||||
|
```python
|
||||||
|
# 문제 코드
|
||||||
|
image_data = data.get('image_data', '').strip() # None이면 에러
|
||||||
|
|
||||||
|
# 수정 코드
|
||||||
|
image_data = (data.get('image_data') or '').strip()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2차 원인: 변수 리셋 타이밍 문제 (프론트엔드) ⭐ 핵심
|
||||||
|
```javascript
|
||||||
|
// 문제 코드
|
||||||
|
async function submitCapture() {
|
||||||
|
const barcode = replaceTargetBarcode;
|
||||||
|
const productName = replaceTargetName;
|
||||||
|
closeReplaceModal(); // ← 여기서 capturedImageData = null 로 리셋됨!
|
||||||
|
|
||||||
|
// API 호출 시 capturedImageData가 이미 null
|
||||||
|
body: JSON.stringify({
|
||||||
|
image_data: capturedImageData, // null!
|
||||||
|
...
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정 코드
|
||||||
|
async function submitCapture() {
|
||||||
|
const barcode = replaceTargetBarcode;
|
||||||
|
const productName = replaceTargetName;
|
||||||
|
const imageData = capturedImageData; // ← 미리 복사!
|
||||||
|
|
||||||
|
closeReplaceModal();
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
image_data: imageData, // 복사된 값 사용
|
||||||
|
...
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3차 원인: 브라우저 캐시
|
||||||
|
- 코드 수정 후에도 브라우저가 이전 JS를 캐시
|
||||||
|
- **Ctrl+Shift+R** (강력 새로고침) 필요
|
||||||
|
|
||||||
|
## ✅ 해결 방법
|
||||||
|
|
||||||
|
1. **백엔드**: `None` 값에 대한 방어 코드 추가
|
||||||
|
2. **프론트엔드**: `closeReplaceModal()` 호출 전에 필요한 변수들을 로컬 변수로 복사
|
||||||
|
3. **테스트 시**: 강력 새로고침 (Ctrl+Shift+R) 또는 시크릿 모드 사용
|
||||||
|
|
||||||
|
## 📝 교훈
|
||||||
|
|
||||||
|
1. **모달 닫기 함수에서 상태 리셋 주의**
|
||||||
|
- 모달을 닫으면서 관련 변수를 초기화하는 경우, async 함수에서 순서에 주의
|
||||||
|
|
||||||
|
2. **프론트엔드 디버깅 시 브라우저 캐시 확인**
|
||||||
|
- 코드 수정 후에도 동작이 같다면 캐시 문제 의심
|
||||||
|
|
||||||
|
3. **API 직접 테스트로 문제 범위 좁히기**
|
||||||
|
- `requests` 또는 `curl`로 API만 테스트하면 프론트/백엔드 문제 구분 가능
|
||||||
|
|
||||||
|
## 🔧 관련 파일
|
||||||
|
- `backend/app.py` - `/api/admin/product-images/<barcode>/upload` 엔드포인트
|
||||||
|
- `backend/templates/admin_product_images.html` - 카메라 촬영 UI 및 JS
|
||||||
Loading…
Reference in New Issue
Block a user