diff --git a/backend/pmr_api.py b/backend/pmr_api.py index 14ff637..d806c78 100644 --- a/backend/pmr_api.py +++ b/backend/pmr_api.py @@ -70,6 +70,7 @@ def get_prescriptions_by_date(): PreSerial, Day_Serial, PassDay, + Indate, Paname, PaNum, CusCode, @@ -81,7 +82,7 @@ def get_prescriptions_by_date(): PRICE_P, PRICE_C FROM PS_MAIN - WHERE PassDay = ? + WHERE Indate = ? ORDER BY Day_Serial ASC """, (date_yyyymmdd,)) @@ -112,7 +113,9 @@ def get_prescriptions_by_date(): prescriptions.append({ 'prescription_id': row.PreSerial, 'order_number': row.Day_Serial, - 'date': row.PassDay, + 'date': row.Indate, # 조제일 기준 + 'issue_date': row.PassDay, # 처방전 발행일 + 'dispense_date': row.Indate, # 조제일 'patient_name': row.Paname, 'patient_id': row.PaNum[:6] + '******' if row.PaNum and len(row.PaNum) > 6 else row.PaNum, 'patient_code': row.CusCode, @@ -221,8 +224,6 @@ def get_prescription_detail(prescription_id): 'sung_code': row.SUNG_CODE or '' }) - conn.close() - # 나이/성별 계산 panum = rx_row.PaNum or '' age = None @@ -255,6 +256,23 @@ def get_prescription_detail(prescription_id): 'name_2': disease_name_2 } + # 환자 특이사항(CUSETC) 조회 - CD_PERSON 테이블 + cusetc = '' + cus_code = rx_row.CusCode + if cus_code: + try: + # PM_BASE.dbo.CD_PERSON에서 조회 + cursor.execute(""" + SELECT CUSETC FROM PM_BASE.dbo.CD_PERSON WHERE CUSCODE = ? + """, (cus_code,)) + person_row = cursor.fetchone() + if person_row and person_row.CUSETC: + cusetc = person_row.CUSETC + except Exception as e: + logging.warning(f"특이사항 조회 실패: {e}") + + conn.close() + return jsonify({ 'success': True, 'prescription': { @@ -271,8 +289,10 @@ def get_prescription_detail(prescription_id): 'patient': { 'name': rx_row.Paname, 'code': rx_row.CusCode, + 'cus_code': rx_row.CusCode, # 호환성 'age': age, - 'gender': gender + 'gender': gender, + 'cusetc': cusetc # 특이사항 }, 'disease_info': disease_info, 'medications': medications, @@ -822,6 +842,102 @@ def get_patient_otc_history(cus_code): # PAAI (Pharmacist Assistant AI) API # ───────────────────────────────────────────────────────────── +@pmr_bp.route('/api/paai/result/', methods=['GET']) +def paai_get_result(pre_serial): + """ + PAAI 분석 결과 조회 (캐시) + + Response: + - 결과 있음: {exists: true, status: "success", result: {...}, log_id: 123} + - 생성 중: {exists: true, status: "generating", log_id: 123} + - 없음: {exists: false} + """ + import sqlite3 + import json + from pathlib import Path + + try: + db_path = Path(__file__).parent / 'db' / 'paai_logs.db' + if not db_path.exists(): + return jsonify({'exists': False}) + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 해당 처방의 최신 성공 로그 조회 + cursor.execute(''' + SELECT id, status, created_at, patient_name, + disease_name_1, disease_name_2, + current_med_count, kims_interaction_count, kims_has_severe, + kims_response_time_ms, ai_response_time_ms, + ai_response + FROM paai_logs + WHERE pre_serial = ? + ORDER BY id DESC + LIMIT 1 + ''', (pre_serial,)) + + row = cursor.fetchone() + conn.close() + + if not row: + return jsonify({'exists': False}) + + status = row['status'] + + # 생성 중 상태 + if status in ('pending', 'kims_done', 'generating'): + return jsonify({ + 'exists': True, + 'status': 'generating', + 'log_id': row['id'] + }) + + # 에러 상태 + if status == 'error': + return jsonify({ + 'exists': True, + 'status': 'error', + 'log_id': row['id'] + }) + + # 성공 상태 - 전체 결과 반환 + ai_response = {} + if row['ai_response']: + try: + ai_response = json.loads(row['ai_response']) + except: + pass + + return jsonify({ + 'exists': True, + 'status': 'success', + 'log_id': row['id'], + 'result': { + 'created_at': row['created_at'], + 'patient_name': row['patient_name'], + 'disease_name_1': row['disease_name_1'], + 'disease_name_2': row['disease_name_2'], + 'medication_count': row['current_med_count'], + 'kims_summary': { + 'interaction_count': row['kims_interaction_count'], + 'has_severe': bool(row['kims_has_severe']) + }, + 'timing': { + 'kims_ms': row['kims_response_time_ms'], + 'ai_ms': row['ai_response_time_ms'], + 'total_ms': (row['kims_response_time_ms'] or 0) + (row['ai_response_time_ms'] or 0) + }, + 'analysis': ai_response + } + }) + + except Exception as e: + logging.error(f"PAAI 결과 조회 오류: {e}") + return jsonify({'exists': False, 'error': str(e)}) + + @pmr_bp.route('/api/paai/analyze', methods=['POST']) def paai_analyze(): """ diff --git a/backend/templates/pmr.html b/backend/templates/pmr.html index 2e28f20..c469db3 100644 --- a/backend/templates/pmr.html +++ b/backend/templates/pmr.html @@ -161,6 +161,51 @@ transform: scale(1.05); } + /* 환자 정보 행 */ + .detail-header .patient-row { + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + } + + /* 특이사항 인라인 (환자명 옆) */ + .detail-header .cusetc-inline { + cursor: pointer; + transition: all 0.2s; + font-size: 0.9rem; + line-height: 1.4; + max-width: 400px; + padding: 6px 12px; + border-radius: 8px; + margin-left: auto; + } + .detail-header .cusetc-inline.has-note { + background: linear-gradient(135deg, #fae8ff, #f5d0fe); + color: #86198f; + border: 1px solid #e879f9; + } + .detail-header .cusetc-inline.no-note { + background: #f9fafb; + color: #9ca3af; + border: 1px dashed #d1d5db; + font-size: 0.85rem; + } + .detail-header .cusetc-inline:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } + .detail-header .cusetc-inline .cusetc-label { + font-weight: 600; + margin-right: 6px; + } + .detail-header .cusetc-inline .cusetc-text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + /* PAAI 버튼 */ .detail-header .rx-info .paai-badge { background: linear-gradient(135deg, #10b981, #059669) !important; @@ -412,6 +457,115 @@ vertical-align: middle; } + /* 특이사항 모달 */ + .cusetc-modal { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.6); + z-index: 1000; + align-items: center; + justify-content: center; + } + .cusetc-modal-content { + background: #fff; + border-radius: 16px; + width: 90%; + max-width: 500px; + box-shadow: 0 25px 50px rgba(0,0,0,0.3); + overflow: hidden; + } + .cusetc-modal-header { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: #fff; + padding: 18px 24px; + display: flex; + justify-content: space-between; + align-items: center; + } + .cusetc-modal-header h3 { + font-size: 1.2rem; + margin: 0; + } + .cusetc-modal-close { + background: rgba(255,255,255,0.2); + border: none; + color: #fff; + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 1.3rem; + cursor: pointer; + transition: background 0.2s; + } + .cusetc-modal-close:hover { background: rgba(255,255,255,0.3); } + .cusetc-modal-body { + padding: 24px; + } + .cusetc-patient-info { + background: #f9fafb; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.95rem; + color: #374151; + } + .cusetc-modal-body textarea { + width: 100%; + min-height: 120px; + padding: 14px; + border: 2px solid #e5e7eb; + border-radius: 10px; + font-size: 1rem; + font-family: inherit; + resize: vertical; + transition: border-color 0.2s; + box-sizing: border-box; + } + .cusetc-modal-body textarea:focus { + outline: none; + border-color: #f59e0b; + } + .cusetc-hint { + font-size: 0.85rem; + color: #6b7280; + margin-top: 10px; + } + .cusetc-modal-footer { + padding: 16px 24px; + background: #f9fafb; + display: flex; + justify-content: flex-end; + gap: 10px; + } + .cusetc-btn-cancel { + padding: 10px 20px; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s; + } + .cusetc-btn-cancel:hover { + background: #f3f4f6; + } + .cusetc-btn-save { + padding: 10px 24px; + background: linear-gradient(135deg, #f59e0b, #d97706); + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + .cusetc-btn-save:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4); + } + /* OTC 모달 */ .otc-modal { display: none; @@ -820,8 +974,11 @@
@@ -871,6 +1028,25 @@
+ +
+
+
+

📝 환자 특이사항

+ +
+
+
+ +
💡 Tip: 복약지도 시 참고할 정보를 기록하세요
+
+ +
+
+
@@ -1001,6 +1177,44 @@ } } + // 환자 목록 새로고침 (현재 선택 유지) + async function refreshPatientList() { + const container = document.getElementById('patientItems'); + const date = document.getElementById('dateSelect').value; + const selectedId = currentPrescriptionId; // 현재 선택된 환자 ID 저장 + + try { + const res = await fetch(`/pmr/api/prescriptions?date=${date}`); + const data = await res.json(); + + if (data.success && data.prescriptions.length > 0) { + container.innerHTML = data.prescriptions.map(p => ` +
+
+ ${p.patient_name || '이름없음'} + ${p.order_number || '-'} +
+
+ ${p.age ? p.age + '세' : ''} ${p.gender || ''} + ${p.time ? '• ' + p.time : ''} +
+
${p.hospital || ''} ${p.doctor ? '(' + p.doctor + ')' : ''}
+
+ `).join(''); + document.getElementById('patientCount').textContent = data.count + '명'; + + // 통계도 갱신 + loadStats(date); + + console.log('[PMR] 환자 목록 갱신됨:', data.count + '명'); + } + } catch (err) { + console.error('목록 갱신 오류:', err); + } + } + // 환자 선택 async function selectPatient(prescriptionId, element) { // UI 활성화 @@ -1019,13 +1233,14 @@ // PAAI용 처방 데이터 저장 currentPrescriptionData = { pre_serial: prescriptionId, - cus_code: data.patient.cus_code, + cus_code: data.patient.cus_code || data.patient.code, name: data.patient.name, st1: data.disease_info?.code_1 || '', st1_name: data.disease_info?.name_1 || '', st2: data.disease_info?.code_2 || '', st2_name: data.disease_info?.name_2 || '', - medications: data.medications || [] + medications: data.medications || [], + cusetc: data.patient.cusetc || '' // 특이사항 }; // 헤더 업데이트 @@ -1046,6 +1261,19 @@ } } + // 특이사항 (환자명 옆 인라인 표시) + const cusetc = data.patient.cusetc || ''; + const cusetcInline = document.getElementById('cusetcInline'); + if (cusetc) { + cusetcInline.className = 'cusetc-inline has-note'; + cusetcInline.innerHTML = `📝${escapeHtml(cusetc)}`; + cusetcInline.title = cusetc; + } else { + cusetcInline.className = 'cusetc-inline no-note'; + cusetcInline.innerHTML = '+ 특이사항 추가'; + cusetcInline.title = ''; + } + document.getElementById('rxInfo').innerHTML = ` 🏥 ${data.prescription.hospital || '-'} 👨‍⚕️ ${data.prescription.doctor || '-'} @@ -1375,6 +1603,8 @@ // OTC 구매 이력 체크 async function checkOtcHistory(cusCode) { + const preSerial = currentPrescriptionData?.pre_serial; + try { const res = await fetch(`/pmr/api/patient/${cusCode}/otc?limit=20`); const data = await res.json(); @@ -1389,12 +1619,17 @@ otcData = null; } - // PAAI 버튼 추가 (항상 표시) + // 서버에서 기존 PAAI 결과 확인 (트리거가 미리 생성한 결과) + await checkPaaiStatus(preSerial); + + // PAAI 버튼 추가 (캐시/진행중/기본 상태에 따라) addPaaiButton(); } catch (err) { console.error('OTC check error:', err); otcData = null; - // OTC 오류여도 PAAI 버튼은 추가 + + // OTC 오류여도 PAAI 상태 확인 및 버튼 추가 + await checkPaaiStatus(preSerial); addPaaiButton(); } } @@ -1468,6 +1703,88 @@ document.getElementById('otcModal').style.display = 'none'; } + // ───────────────────────────────────────────────────────────── + // 특이사항 모달 함수들 + // ───────────────────────────────────────────────────────────── + + function openCusetcModal() { + if (!currentPrescriptionData) return; + + const modal = document.getElementById('cusetcModal'); + const patientInfo = document.getElementById('cusetcPatientInfo'); + const textarea = document.getElementById('cusetcTextarea'); + + // 환자 정보 표시 + patientInfo.innerHTML = ` + ${currentPrescriptionData.name || '환자'} + 고객코드: ${currentPrescriptionData.cus_code || '-'} + `; + + // 기존 특이사항 로드 + textarea.value = currentPrescriptionData.cusetc || ''; + + // 모달 표시 + modal.style.display = 'flex'; + textarea.focus(); + } + + function closeCusetcModal() { + document.getElementById('cusetcModal').style.display = 'none'; + } + + async function saveCusetc() { + if (!currentPrescriptionData || !currentPrescriptionData.cus_code) { + alert('❌ 환자 정보가 없습니다.'); + return; + } + + const textarea = document.getElementById('cusetcTextarea'); + const newCusetc = textarea.value.trim(); + const cusCode = currentPrescriptionData.cus_code; + + try { + const res = await fetch(`/api/members/${cusCode}/cusetc`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cusetc: newCusetc }) + }); + + const data = await res.json(); + + if (data.success) { + // 로컬 데이터 업데이트 + currentPrescriptionData.cusetc = newCusetc; + + // 뱃지 업데이트 + updateCusetcBadge(newCusetc); + + closeCusetcModal(); + + // 토스트 알림 + showPaaiToast(currentPrescriptionData.name, '특이사항이 저장되었습니다.', 'completed'); + } else { + alert('❌ ' + (data.error || '저장 실패')); + } + } catch (err) { + alert('❌ 오류: ' + err.message); + } + } + + function updateCusetcBadge(cusetc) { + const cusetcInline = document.getElementById('cusetcInline'); + if (!cusetcInline) return; + + if (cusetc) { + cusetcInline.className = 'cusetc-inline has-note'; + cusetcInline.innerHTML = `📝${escapeHtml(cusetc)}`; + cusetcInline.title = cusetc; + } else { + cusetcInline.className = 'cusetc-inline no-note'; + cusetcInline.innerHTML = '+ 특이사항 추가'; + cusetcInline.title = ''; + } + } + // ───────────────────────────────────────────────────────────── // PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식 // ───────────────────────────────────────────────────────────── @@ -1476,6 +1793,43 @@ const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } } const paaiPendingRequests = new Set(); // 진행 중인 요청 + // 서버에서 기존 PAAI 결과 조회 (트리거 모듈이 미리 생성한 결과) + async function checkPaaiStatus(preSerial) { + if (!preSerial) return; + + // 이미 캐시에 있으면 스킵 + if (paaiResultCache[preSerial]) return; + + try { + const res = await fetch(`/pmr/api/paai/result/${preSerial}`); + const data = await res.json(); + + if (data.exists) { + if (data.status === 'success' && data.result) { + // 성공한 결과 캐시에 저장 + const patientName = data.result.patient_name || '환자'; + paaiResultCache[preSerial] = { + result: { + success: true, + log_id: data.log_id, + analysis: data.result.analysis, + kims_summary: data.result.kims_summary, + timing: data.result.timing + }, + patientName: patientName + }; + console.log(`[PAAI] 기존 결과 로드: ${preSerial} (${patientName})`); + } else if (data.status === 'generating') { + // 생성 중인 요청으로 표시 + paaiPendingRequests.add(preSerial); + console.log(`[PAAI] 생성 중: ${preSerial}`); + } + } + } catch (err) { + console.error('PAAI status check error:', err); + } + } + function addPaaiButton() { const rxInfo = document.getElementById('rxInfo'); if (!rxInfo || rxInfo.querySelector('.paai-badge')) return; @@ -1967,7 +2321,12 @@ // ═══════════════════════════════════════════════════════════════════════════ (function() { - const TRIGGER_WS_URL = 'ws://localhost:8765'; + // WebSocket 연결 URL 결정 + // - HTTPS(프록시) 접속 → wss:// 프록시 경로 사용 + // - HTTP(내부) 접속 → ws:// 직접 연결 + const TRIGGER_WS_URL = location.protocol === 'https:' + ? `wss://${location.host}/ws` // NPM 프록시 경로 + : `ws://${location.hostname}:8765`; // 내부 직접 연결 const TRIGGER_DEBUG = true; let triggerWs = null; @@ -1991,6 +2350,7 @@ triggerLog('✅ 연결됨'); triggerConnected = true; updateTriggerIndicator(true); + showToast('🔗 처방감지 연결됨', 'success'); if (triggerReconnectTimer) { clearTimeout(triggerReconnectTimer); triggerReconnectTimer = null; @@ -2012,6 +2372,7 @@ triggerConnected = false; triggerWs = null; updateTriggerIndicator(false); + showToast('🔌 처방감지 연결 끊김 (재연결 중...)', 'warning'); // 3초 후 재연결 triggerReconnectTimer = setTimeout(() => { @@ -2036,9 +2397,13 @@ switch (event) { case 'prescription_detected': showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 감지됨...', '📋'); + // 환자 목록 자동 갱신 (현재 선택 유지) + refreshPatientList(); break; case 'prescription_updated': showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄'); + // 환자 목록 갱신 (수정된 정보 반영) + refreshPatientList(); break; case 'prescription_deleted': removeTriggerToast(data.pre_serial); diff --git a/docs/WEBSOCKET_PROXY_TROUBLESHOOTING.md b/docs/WEBSOCKET_PROXY_TROUBLESHOOTING.md new file mode 100644 index 0000000..48b4efe --- /dev/null +++ b/docs/WEBSOCKET_PROXY_TROUBLESHOOTING.md @@ -0,0 +1,278 @@ +# WebSocket 프록시 트러블슈팅 가이드 + +## 개요 + +PAAI 처방감지 시스템에서 WebSocket(wss://)을 Nginx Proxy Manager(NPM)를 통해 프록시하는 과정에서 발생한 문제들과 해결 방법을 정리한 문서입니다. + +**환경:** +- Nginx Proxy Manager (NPM): Docker 컨테이너, CT 150 (192.168.0.105) +- WebSocket 서버: trigger_server.py (192.168.0.14:8765) +- 프론트엔드: https://mile.0bin.in/pmr/ +- Proxmox VE: 192.168.0.119 + +--- + +## 문제 1: WebSocket 404 오류 + +### 증상 +``` +WebSocket connection to 'wss://mile.0bin.in/ws' failed +``` +nginx 액세스 로그에 `/ws` 요청이 404로 응답됨. + +### 원인 +NPM에서 `/ws` 경로에 대한 프록시 설정이 없었음. + +### 해결 방법 + +**NPM 웹 UI에서 Advanced 설정 추가:** + +1. NPM 관리자 접속 +2. **Proxy Hosts** → **mile.0bin.in** → **Edit** +3. **Advanced** 탭 클릭 +4. 아래 내용 입력: + +```nginx +location /ws { + proxy_pass http://192.168.0.14:8765; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; +} +``` + +5. **Save** 클릭 + +### ⚠️ 주의사항 + +**절대 conf 파일 직접 수정하지 말 것!** + +NPM은 설정을 SQLite DB에 저장하고, 컨테이너 재시작 시 DB에서 conf 파일을 재생성합니다. +`/data/nginx/proxy_host/167.conf` 같은 파일을 직접 수정해도 재시작 시 덮어씌워집니다. + +**반드시 NPM 웹 UI의 Advanced 탭을 사용하세요.** + +--- + +## 문제 2: duplicate location 오류 + +### 증상 +``` +nginx: [emerg] duplicate location "/ws" in /data/nginx/custom/server_proxy.conf:1 +nginx: configuration file /etc/nginx/nginx.conf test failed +``` +모든 프록시가 작동하지 않음! + +### 원인 +`/data/nginx/custom/server_proxy.conf`에 `/ws` location을 추가했는데, +NPM Advanced 설정에서도 `/ws`를 추가하여 중복 발생. + +### 해결 방법 + +중복 파일 삭제: +```bash +# Proxmox에서 실행 +pct exec 150 -- docker exec npm rm /data/nginx/custom/server_proxy.conf +pct exec 150 -- docker exec npm nginx -t +pct exec 150 -- docker exec npm nginx -s reload +``` + +### 예방책 +- `/data/nginx/custom/` 폴더는 건드리지 않기 +- 모든 커스텀 설정은 **NPM 웹 UI Advanced 탭**에서만 추가 + +--- + +## 문제 3: HTTP/2와 WebSocket 충돌 + +### 증상 +``` +curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1) +``` +nginx 프록시를 통한 WebSocket 핸드셰이크 실패. + +### 원인 +- NPM이 HTTPS에서 HTTP/2를 사용 +- 기존 HTTP/2는 WebSocket을 지원하지 않음 (RFC 8441 이후 지원) +- nginx 1.25.1+ 필요 + +### 해결 방법 + +**방법 1: 내부 HTTP 접속 (권장, 간단)** +``` +http://192.168.0.14:7001/pmr/ +``` +HTTP → ws:// 직접 연결로 HTTP/2 문제 회피 + +**방법 2: 프론트엔드에서 분기 처리** +```javascript +const TRIGGER_WS_URL = location.protocol === 'https:' + ? `wss://${location.host}/ws` // 외부: NPM 프록시 + : `ws://${location.hostname}:8765`; // 내부: 직접 연결 +``` + +**방법 3: 별도 WebSocket 전용 서브도메인** +- ws.mile.0bin.in 생성 +- HTTP/2 비활성화 설정 + +--- + +## 문제 4: nginx 설정 반영 안 됨 + +### 증상 +conf 파일에 설정이 있지만 `nginx -T`에서 확인 안 됨. + +### 진단 방법 + +```bash +# 1. conf 파일 확인 +pct exec 150 -- docker exec npm cat /data/nginx/proxy_host/167.conf | grep 8765 + +# 2. nginx 로드된 설정 확인 +pct exec 150 -- docker exec npm nginx -T 2>&1 | grep 8765 + +# 3. 결과 비교 +# 파일에는 있는데 nginx -T에 없으면 → reload 필요 또는 문법 오류 +``` + +### 해결 방법 + +```bash +# 문법 체크 +pct exec 150 -- docker exec npm nginx -t + +# 문제 없으면 reload +pct exec 150 -- docker exec npm nginx -s reload + +# 그래도 안 되면 컨테이너 재시작 +pct exec 150 -- docker restart npm +``` + +--- + +## 유용한 진단 명령어 + +### NPM 상태 확인 +```bash +# 컨테이너 상태 +pct exec 150 -- docker ps | grep npm + +# nginx 설정 테스트 +pct exec 150 -- docker exec npm nginx -t + +# nginx reload +pct exec 150 -- docker exec npm nginx -s reload + +# 컨테이너 재시작 +pct exec 150 -- docker restart npm +``` + +### 로그 확인 +```bash +# 특정 호스트 액세스 로그 +pct exec 150 -- docker exec npm tail -50 /data/logs/proxy-host-167_access.log + +# 에러 로그 +pct exec 150 -- docker exec npm tail -20 /data/logs/proxy-host-167_error.log + +# /ws 요청만 필터 +pct exec 150 -- docker exec npm tail -100 /data/logs/proxy-host-167_access.log | grep ws +``` + +### WebSocket 연결 테스트 +```bash +# 직접 연결 테스트 (CT 150에서) +curl -v --max-time 5 \ + -H "Connection: Upgrade" \ + -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" \ + http://192.168.0.14:8765 + +# 성공 시: HTTP/1.1 101 Switching Protocols +``` + +### 프록시 호스트 번호 찾기 +```bash +# 도메인으로 설정 파일 찾기 +pct exec 150 -- docker exec npm grep -r "mile.0bin" /data/nginx/proxy_host/ +# → /data/nginx/proxy_host/167.conf +``` + +--- + +## 최종 작동 구성 + +### 네트워크 구조 +``` +[브라우저] + │ + │ HTTPS (외부) 또는 HTTP (내부) + ▼ +[NPM - 192.168.0.105:443/80] + │ + ├─ /pmr/* → http://192.168.0.14:7001 (Flask) + │ + └─ /ws → http://192.168.0.14:8765 (WebSocket) + +[Flask 서버 - 192.168.0.14:7001] + │ + └─ PMR 웹 앱 + +[Trigger 서버 - 192.168.0.14:8765] + │ + └─ WebSocket 처방 감지 알림 +``` + +### 프론트엔드 WebSocket 연결 코드 +```javascript +// pmr.html +const TRIGGER_WS_URL = location.protocol === 'https:' + ? `wss://${location.host}/ws` // 외부: NPM 프록시 + : `ws://${location.hostname}:8765`; // 내부: 직접 연결 +``` + +### NPM Advanced 설정 (mile.0bin.in) +```nginx +location /ws { + proxy_pass http://192.168.0.14:8765; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; +} +``` + +--- + +## 체크리스트 + +WebSocket 프록시 설정 시 확인 사항: + +- [ ] WebSocket 서버(trigger_server.py) 실행 중인가? +- [ ] 8765 포트 리스닝 중인가? (`netstat -ano | findstr :8765`) +- [ ] NPM Advanced에 /ws location 설정했는가? +- [ ] `/data/nginx/custom/` 폴더에 중복 설정 없는가? +- [ ] `nginx -t` 통과하는가? +- [ ] nginx reload 했는가? +- [ ] 브라우저 캐시 비웠는가? (Ctrl+Shift+R) + +--- + +## 교훈 + +1. **NPM 설정은 반드시 웹 UI로** - conf 파일 직접 수정 금지 +2. **duplicate location 주의** - custom 폴더와 Advanced 설정 중복 확인 +3. **HTTP/2 + WebSocket = 문제** - 내부 HTTP 접속 또는 별도 서브도메인 사용 +4. **변경 후 항상 테스트** - `nginx -t` 습관화 +5. **문제 생기면 로그 먼저** - access_log, error_log 확인 + +--- + +*작성일: 2026-03-05* +*작성: 용림 (Clawdbot)*