PharmQ SaaS 구독 서비스 관리 시스템 완전 구현

📋 기획 및 설계:
- PharmQ SaaS 서비스 기획서 작성
- 구독 서비스 라인업 정의 (클라우드PC, AI CCTV, CRM)
- DB 스키마 설계 및 API 아키텍처 설계

🗄️ 데이터베이스 구조:
- service_products: 서비스 상품 마스터 테이블
- pharmacy_subscriptions: 약국별 구독 현황 테이블
- subscription_usage_logs: 서비스 이용 로그 테이블
- billing_history: 결제 이력 테이블
- 샘플 데이터 자동 생성 (21개 구독, 월 118만원 매출)

🔧 백엔드 API 구현:
- 구독 현황 통계 API (/api/subscriptions/stats)
- 약국별 구독 조회 API (/api/pharmacies/subscriptions)
- 구독 상세 정보 API (/api/pharmacy/{id}/subscriptions)
- 구독 생성/해지 API (/api/subscriptions)

🖥️ 프론트엔드 UI 구현:
- 대시보드 구독 현황 카드 (월 매출, 구독 수, 구독률 등)
- 약국 목록에 구독 상태 아이콘 및 월 구독료 표시
- 약국 상세 페이지 구독 서비스 섹션 추가
- 실시간 구독 생성/해지 기능 구현

 주요 특징:
- 서비스별 색상 코딩 및 이모지 아이콘 시스템
- 실시간 업데이트 (구독 생성/해지 즉시 반영)
- 반응형 디자인 (모바일/태블릿 최적화)
- 툴팁 기반 상세 정보 표시

📊 현재 구독 현황:
- 총 월 매출: ₩1,180,000
- 구독 약국: 10/14개 (71.4%)
- AI CCTV: 6개 약국, CRM: 10개 약국, 클라우드PC: 5개 약국

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
시골약사 2025-09-11 19:48:12 +09:00
parent c37cf023c1
commit 35ecd4748e
15 changed files with 3967 additions and 0 deletions

View File

@ -0,0 +1,508 @@
# 🌐 Flask + Jinja2 Headplane 고도화 관리자 페이지 개발 계획
## 📋 프로젝트 개요
### 개발 목표
기존 Headplane UI를 포크하지 않고, **Flask + Jinja2**로 별도 관리자 페이지를 구축하여 Headscale 데이터베이스와 직접 연동하는 고도화된 관리 시스템 개발
### 핵심 컨셉
- **기존 Headplane**: 기본 기능 유지 (3000번 포트)
- **Flask Admin**: 고도화된 관리 기능 (5000번 포트)
- **데이터 통합**: 동일한 SQLite DB 공유로 실시간 동기화
- **팜큐 특화**: 약국 관리에 최적화된 UI/UX
## 🏗️ 아키텍처 설계
### 시스템 구조
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Headplane UI │ │ Flask Admin │ │ Headscale API │
│ (포트: 3000) │ │ (포트: 5000) │ │ (포트: 8070) │
│ 기본 기능 │ │ 고도화 기능 │ │ 백엔드 API │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌──────────────────┐
│ SQLite Database │
│ (공유 데이터) │
└──────────────────┘
```
### 포트 구성
- **Headscale API**: 8070 (기존 유지)
- **Headplane UI**: 3000 (기존 유지)
- **Flask Admin**: 5000 (신규 추가)
## 📂 Flask 프로젝트 구조
```
farmq-admin/
├── app.py # Flask 애플리케이션 메인
├── config.py # 설정 파일
├── requirements.txt # Python 의존성
├── models/
│ ├── __init__.py
│ ├── headscale_models.py # SQLAlchemy 모델 (재사용)
│ └── pharmacy_models.py # 팜큐 확장 모델
├── routes/
│ ├── __init__.py
│ ├── dashboard.py # 메인 대시보드
│ ├── pharmacy.py # 약국 관리
│ ├── machines.py # 머신 관리 (고도화)
│ ├── users.py # 사용자 관리 (고도화)
│ ├── monitoring.py # 실시간 모니터링
│ └── api.py # REST API 엔드포인트
├── templates/
│ ├── base.html # 기본 레이아웃
│ ├── dashboard/
│ │ ├── index.html # 메인 대시보드
│ │ └── stats.html # 통계 대시보드
│ ├── pharmacy/
│ │ ├── list.html # 약국 목록
│ │ ├── detail.html # 약국 상세
│ │ ├── create.html # 약국 등록
│ │ └── edit.html # 약국 수정
│ ├── machines/
│ │ ├── list.html # 머신 목록 (고도화)
│ │ ├── detail.html # 머신 상세 (하드웨어 정보)
│ │ └── monitoring.html # 실시간 모니터링
│ └── users/
│ ├── list.html # 사용자 목록 (약국 정보 포함)
│ └── detail.html # 사용자 상세
├── static/
│ ├── css/
│ │ ├── bootstrap.min.css # Bootstrap 5
│ │ ├── custom.css # 커스텀 스타일
│ │ └── dashboard.css # 대시보드 전용 스타일
│ ├── js/
│ │ ├── bootstrap.min.js # Bootstrap JS
│ │ ├── chart.min.js # Chart.js 라이브러리
│ │ ├── dashboard.js # 대시보드 JS
│ │ └── monitoring.js # 실시간 모니터링 JS
│ └── img/
│ ├── logo.png # 팜큐 로고
│ └── icons/ # 아이콘들
├── utils/
│ ├── __init__.py
│ ├── database.py # DB 연결 유틸리티
│ ├── auth.py # 인증 관련
│ ├── monitoring.py # 모니터링 데이터 수집
│ └── proxmox.py # Proxmox API 연동
└── docker/
├── Dockerfile # Flask 앱용 도커파일
└── docker-compose.yml # 통합 컨테이너 구성
```
## 🎨 UI/UX 설계
### 디자인 컨셉
- **Modern Dashboard**: Bootstrap 5 기반 반응형 디자인
- **팜큐 브랜딩**: 약국 관리에 특화된 색상/아이콘 사용
- **Korean-First**: 한국어 우선 인터페이스
- **Mobile Responsive**: 모바일/태블릿 완벽 지원
### 메인 대시보드 레이아웃
```
┌────────────────────────────────────────────────────────────┐
│ 🏥 팜큐 약국 관리 시스템 [관리자: admin] [로그아웃] │
├────────────────────────────────────────────────────────────┤
│ [대시보드] [약국관리] [머신관리] [사용자관리] [모니터링] [설정] │
├────────────────────────────────────────────────────────────┤
│ 📊 전체 현황 │
│ ┌──────────┬──────────┬──────────┬──────────────────────┐ │
│ │총 약국 수 │온라인 │오프라인 │평균 CPU 온도 │ │
│ │ 100 │ 95 │ 5 │ 62°C │ │
│ └──────────┴──────────┴──────────┴──────────────────────┘ │
│ │
│ 🚨 실시간 알림 📈 성능 차트 │
│ ┌─────────────────────────┐ ┌────────────────────┐ │
│ │• 부산해운약국: CPU 85°C │ │ [CPU 사용률 차트] │ │
│ │• 대구중앙약국: 디스크95% │ │ [메모리 사용률] │ │
│ │• 서울약국: 연결 끊김 │ │ [네트워크 트래픽] │ │
│ └─────────────────────────┘ └────────────────────┘ │
│ │
│ 📋 약국별 상태 (실시간) │
│ ┌─────────────┬────────┬────────┬────────┬──────────────┐ │
│ │약국명 │상태 │CPU온도 │메모리 │마지막 접속 │ │
│ ├─────────────┼────────┼────────┼────────┼──────────────┤ │
│ │서울중앙약국 │🟢 온라인│ 65°C │ 80% │ 2분 전 │ │
│ │부산해운약국 │🟡 경고 │ 85°C │ 60% │ 5분 전 │ │
│ │대구중앙약국 │🔴 위험 │ 70°C │ 95% │ 10분 전 │ │
│ └─────────────┴────────┴────────┴────────┴──────────────┘ │
└────────────────────────────────────────────────────────────┘
```
## 🎯 핵심 기능 명세
### 1. 통합 대시보드
```python
# routes/dashboard.py
@app.route('/')
def dashboard():
stats = {
'total_pharmacies': get_pharmacy_count(),
'online_machines': get_online_machines_count(),
'offline_machines': get_offline_machines_count(),
'avg_cpu_temp': get_average_cpu_temperature(),
'alerts': get_active_alerts(),
'recent_activities': get_recent_activities()
}
return render_template('dashboard/index.html', stats=stats)
```
### 2. 약국 관리 시스템
#### 2-1. 약국 목록 페이지
```html
<!-- templates/pharmacy/list.html -->
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between">
<h5>🏥 약국 관리</h5>
<button class="btn btn-primary" onclick="location.href='/pharmacy/create'">
<i class="fas fa-plus"></i> 새 약국 등록
</button>
</div>
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>약국명</th>
<th>사업자번호</th>
<th>담당자</th>
<th>연결된 머신</th>
<th>상태</th>
<th>마지막 접속</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for pharmacy in pharmacies %}
<tr>
<td>
<strong>{{ pharmacy.pharmacy_name }}</strong><br>
<small class="text-muted">{{ pharmacy.address }}</small>
</td>
<td>{{ pharmacy.business_number }}</td>
<td>
{{ pharmacy.manager_name }}<br>
<small class="text-muted">{{ pharmacy.phone }}</small>
</td>
<td>
<span class="badge bg-info">{{ pharmacy.machine_count }}대</span>
</td>
<td>
{% if pharmacy.is_online %}
<span class="badge bg-success">🟢 온라인</span>
{% else %}
<span class="badge bg-danger">🔴 오프라인</span>
{% endif %}
</td>
<td>{{ pharmacy.last_seen_humanized }}</td>
<td>
<div class="btn-group btn-group-sm">
<a href="/pharmacy/{{ pharmacy.id }}" class="btn btn-outline-primary">상세</a>
<a href="/pharmacy/{{ pharmacy.id }}/edit" class="btn btn-outline-warning">수정</a>
<a href="/pharmacy/{{ pharmacy.id }}/monitoring" class="btn btn-outline-info">모니터링</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
```
### 3. 고도화된 머신 관리
#### 3-1. 머신 상세 페이지 (하드웨어 정보 포함)
```html
<!-- templates/machines/detail.html -->
<div class="container-fluid">
<div class="row">
<!-- 기본 정보 -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>🖥️ 머신 기본 정보</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">머신명:</dt>
<dd class="col-sm-8">{{ machine.given_name }}</dd>
<dt class="col-sm-4">호스트명:</dt>
<dd class="col-sm-8">{{ machine.hostname }}</dd>
<dt class="col-sm-4">IP 주소:</dt>
<dd class="col-sm-8">
<code>{{ machine.ipv4 }}</code>
</dd>
<dt class="col-sm-4">소속 약국:</dt>
<dd class="col-sm-8">
<a href="/pharmacy/{{ machine.pharmacy.id }}">
{{ machine.pharmacy.pharmacy_name }}
</a>
</dd>
<dt class="col-sm-4">마지막 접속:</dt>
<dd class="col-sm-8">
{% if machine.is_online() %}
<span class="badge bg-success">🟢 온라인</span>
{% else %}
<span class="badge bg-danger">🔴 {{ machine.last_seen_humanized }}</span>
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
<!-- 하드웨어 사양 -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>⚙️ 하드웨어 사양</h5>
</div>
<div class="card-body">
{% if machine.specs %}
<dl class="row">
<dt class="col-sm-4">CPU:</dt>
<dd class="col-sm-8">{{ machine.specs.cpu_model }} ({{ machine.specs.cpu_cores }}코어)</dd>
<dt class="col-sm-4">RAM:</dt>
<dd class="col-sm-8">{{ machine.specs.ram_gb }}GB</dd>
<dt class="col-sm-4">Storage:</dt>
<dd class="col-sm-8">{{ machine.specs.storage_gb }}GB</dd>
<dt class="col-sm-4">GPU:</dt>
<dd class="col-sm-8">{{ machine.specs.gpu_model or '없음' }}</dd>
</dl>
{% else %}
<p class="text-muted">하드웨어 정보가 등록되지 않았습니다.</p>
<a href="/machines/{{ machine.id }}/specs" class="btn btn-outline-primary btn-sm">
하드웨어 정보 등록
</a>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 실시간 모니터링 -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>📊 실시간 모니터링</h5>
</div>
<div class="card-body">
{% if machine.latest_monitoring %}
<div class="row">
<div class="col-md-3">
<div class="text-center">
<canvas id="cpuChart" width="100" height="100"></canvas>
<h6 class="mt-2">CPU 사용률</h6>
<span class="h4 text-primary">{{ machine.latest_monitoring.cpu_usage }}%</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<canvas id="memoryChart" width="100" height="100"></canvas>
<h6 class="mt-2">메모리 사용률</h6>
<span class="h4 text-info">{{ machine.latest_monitoring.memory_usage }}%</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="display-4 text-warning">🌡️</div>
<h6>CPU 온도</h6>
<span class="h4 text-warning">{{ machine.latest_monitoring.cpu_temperature }}°C</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="display-4 text-success">💾</div>
<h6>디스크 사용률</h6>
<span class="h4 text-success">{{ machine.latest_monitoring.disk_usage }}%</span>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
아직 모니터링 데이터가 없습니다. 잠시 후 다시 확인해주세요.
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<script>
// 실시간 업데이트를 위한 JavaScript
function updateMonitoring() {
fetch(`/api/machines/{{ machine.id }}/monitoring`)
.then(response => response.json())
.then(data => {
// 차트 업데이트 로직
updateCharts(data);
});
}
// 5초마다 업데이트
setInterval(updateMonitoring, 5000);
</script>
```
### 4. 실시간 모니터링 시스템
```python
# routes/monitoring.py
from flask import Blueprint, jsonify
from utils.monitoring import collect_monitoring_data
from utils.proxmox import ProxmoxAPI
monitoring_bp = Blueprint('monitoring', __name__)
@monitoring_bp.route('/api/monitoring/realtime')
def realtime_monitoring():
"""실시간 모니터링 데이터 API"""
data = {
'total_machines': get_total_machines(),
'online_count': get_online_machines_count(),
'alerts': get_active_alerts(),
'performance': get_performance_summary()
}
return jsonify(data)
@monitoring_bp.route('/api/machines/<int:machine_id>/monitoring')
def machine_monitoring(machine_id):
"""특정 머신 모니터링 데이터"""
monitoring_data = collect_monitoring_data(machine_id)
return jsonify(monitoring_data)
```
## 🔧 기술 스택
### Backend
- **Flask 3.0**: 웹 프레임워크
- **SQLAlchemy 2.0**: ORM (기존 모델 재사용)
- **Jinja2**: 템플릿 엔진
- **Flask-Login**: 사용자 인증
- **APScheduler**: 백그라운드 작업 (모니터링 데이터 수집)
### Frontend
- **Bootstrap 5**: CSS 프레임워크
- **Chart.js**: 차트 라이브러리
- **Font Awesome**: 아이콘
- **jQuery**: DOM 조작
- **Socket.io**: 실시간 통신
### 데이터베이스
- **SQLite**: 기존 Headscale DB 공유
- **확장 테이블**: PharmacyInfo, MachineSpecs, MonitoringData
### 배포
- **Docker**: 컨테이너화
- **Nginx**: 리버스 프록시 (옵션)
## 📅 개발 로드맵
### Phase 1: 기본 프레임워크 구축 (1-2일)
- [ ] Flask 애플리케이션 기본 구조 생성
- [ ] SQLAlchemy 모델 연동 (기존 모델 재사용)
- [ ] Bootstrap 기반 기본 템플릿 구성
- [ ] 라우팅 구조 설계
### Phase 2: 핵심 기능 구현 (3-4일)
- [ ] 메인 대시보드 구현
- [ ] 약국 관리 CRUD 기능
- [ ] 머신 관리 고도화 (하드웨어 정보 포함)
- [ ] 사용자 관리 확장 (약국 정보 연동)
### Phase 3: 실시간 기능 (2-3일)
- [ ] 모니터링 데이터 수집 시스템
- [ ] 실시간 차트 및 알림
- [ ] WebSocket 기반 라이브 업데이트
- [ ] Proxmox API 연동
### Phase 4: 통합 및 최적화 (2-3일)
- [ ] 기존 Headplane과 데이터 동기화 테스트
- [ ] Docker 컨테이너화
- [ ] 성능 최적화
- [ ] 사용자 테스트 및 피드백 반영
### Phase 5: 배포 및 운영 (1-2일)
- [ ] Docker Compose 통합 구성
- [ ] 프로덕션 배포
- [ ] 모니터링 및 로깅 설정
- [ ] 사용자 교육 자료 작성
## 💰 예상 리소스
### 개발 시간
- **총 개발 기간**: 8-12일
- **개발자**: 1명 (풀타임)
- **일일 작업량**: 6-8시간
### 기술적 요구사항
- **Python 3.8+**
- **메모리**: 최소 512MB (Flask 앱)
- **디스크**: 추가 100MB (정적 파일 포함)
## 🚀 시작하기
### 1단계: 개발 환경 준비
```bash
# Flask 프로젝트 디렉터리 생성
mkdir farmq-admin
cd farmq-admin
# Python 가상환경 생성
python3 -m venv flask-venv
source flask-venv/bin/activate
# 필수 패키지 설치
pip install flask sqlalchemy jinja2 flask-login apscheduler
```
### 2단계: 기본 구조 생성
```bash
# 프로젝트 구조 생성
mkdir -p {routes,templates,static/{css,js,img},utils,models}
touch app.py config.py requirements.txt
```
### 3단계: 첫 번째 구현
- 기본 Flask 앱 생성
- SQLAlchemy 연동
- 간단한 대시보드 페이지
## 🎯 성공 지표
### 기능적 목표
- [ ] 100개 약국 데이터 완벽 관리
- [ ] 실시간 모니터링 정확도 95% 이상
- [ ] 기존 Headplane과 데이터 100% 동기화
- [ ] 페이지 로딩 시간 2초 이내
### 사용성 목표
- [ ] 관리 업무 효율성 70% 향상
- [ ] 모바일 접근성 완벽 지원
- [ ] 한국어 UI 100% 완성
- [ ] 사용자 만족도 4.8/5.0 이상
이제 이 계획을 바탕으로 Flask 관리자 페이지 개발을 시작하시겠습니까?
---
**📅 작성일**: 2025-09-09
**👤 작성자**: Claude Code Assistant
**🎯 목표**: Headplane UI 고도화를 위한 Flask 기반 관리자 시스템

424
HEADSCALE_COMPLETE_GUIDE.md Normal file
View File

@ -0,0 +1,424 @@
# 🌐 FARMQ Headscale 완전 가이드
## 📚 목차
1. [개념 정리](#개념-정리)
2. [아키텍처 개요](#아키텍처-개요)
3. [포트 구성](#포트-구성)
4. [설치 및 구성](#설치-및-구성)
5. [클라이언트 연결 워크플로우](#클라이언트-연결-워크플로우)
6. [실제 사용 시나리오](#실제-사용-시나리오)
7. [문제 해결](#문제-해결)
---
## 🧠 개념 정리
### Tailscale vs Headscale vs Headplane
#### 1. **Tailscale** (원본 서비스)
- **정의**: 상용 VPN 서비스 (SaaS)
- **특징**:
- 클라우드 기반 coordination server 사용
- 구독 기반 유료 서비스
- 자동화된 관리
- **단점**:
- 데이터가 외부 서버를 거침
- 비용 발생
- 프라이버시 우려
#### 2. **Headscale** (오픈소스 서버)
- **정의**: Tailscale의 coordination server를 대체하는 오픈소스 구현
- **특징**:
- 자체 호스팅 가능
- 완전한 프라이버시 제어
- 무료 사용
- REST API 제공
- **역할**:
- 클라이언트 인증 및 등록
- IP 주소 할당
- 라우팅 테이블 관리
- 키 교환 coordination
#### 3. **Headplane** (웹 UI)
- **정의**: Headscale을 관리하기 위한 웹 인터페이스
- **특징**:
- 브라우저에서 노드 관리
- 시각적 네트워크 상태 확인
- 사용자 및 키 관리
#### 4. **클라이언트 (Tailscale 클라이언트)**
- **정의**: 실제 VPN 연결을 담당하는 클라이언트 프로그램
- **중요**: Tailscale의 **클라이언트 소프트웨어**를 그대로 사용
- **변경점**: 서버 주소만 Headscale 서버로 지정
---
## 🏗️ 아키텍처 개요
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 클라이언트 PC │ │ Headscale │ │ 클라이언트 PC │
│ │ │ 서버 │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Tailscale │◄────┼─┤ Headscale │─┼────┤ │ Tailscale │ │
│ │ Client │ │ │ │ Server │ │ │ │ Client │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ ┌─────────────┐ │ │ │
│ │ │ │ Headplane │ │ │ │
│ │ │ │ Web UI │ │ │ │
│ │ │ └─────────────┘ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└────────────────────────┼────────────────────────┘
┌─────────────────┐
│ FARMQ Flask │
│ Admin Panel │
└─────────────────┘
```
### 데이터 흐름
1. **등록**: 클라이언트 → Headscale 서버 (인증)
2. **키 교환**: Headscale 서버 → 클라이언트들 (P2P 키 정보)
3. **실제 통신**: 클라이언트 ↔ 클라이언트 (직접 P2P)
4. **관리**: Headplane/Flask UI → Headscale API
---
## 🔌 포트 구성
### 현재 FARMQ 설정
| 서비스 | 포트 | 프로토콜 | 용도 | 접근 |
|--------|------|----------|------|------|
| **Headscale Server** | `8070` | HTTP | 클라이언트 등록/관리 | 클라이언트 ← → 서버 |
| **Headplane UI** | `3000` | HTTP | 웹 관리 인터페이스 | 관리자 → 웹브라우저 |
| **FARMQ Admin** | `5001` | HTTP | 한국어 관리 페이지 | 관리자 → 웹브라우저 |
### 중요한 포인트
- **클라이언트가 사용하는 포트**: `8070` (Headscale 서버)
- **관리자가 사용하는 포트**: `3000` (Headplane), `5001` (FARMQ Admin)
- **내부 컨테이너 포트**: `8080` (Docker 내부에서만 사용)
---
## 🛠️ 설치 및 구성
### 1. 서버 구성 (이미 완료)
```bash
# 서버 시작
cd /srv/headscale-setup
docker-compose up -d
# 서비스 확인
docker-compose ps
```
### 2. 클라이언트 설치 과정
#### Step 1: Tailscale 클라이언트 설치
```bash
# Ubuntu/Debian
curl -fsSL https://tailscale.com/install.sh | sh
# 또는 수동 설치
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/$(lsb_release -cs).noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/$(lsb_release -cs).tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list
sudo apt update
sudo apt install tailscale
```
#### Step 2: Headscale 서버에 등록
```bash
# 기본 명령어 형식
sudo tailscale up --login-server=http://[서버IP]:8070 --authkey=[PreAuth키]
# FARMQ 실제 명령어 예시
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey=YOUR_PREAUTH_KEY_HERE \
--hostname=pharmacy-busan-pc1 \
--accept-dns=false
```
---
## 🔄 클라이언트 연결 워크플로우
### 전체 프로세스
```mermaid
sequenceDiagram
participant Admin as 관리자
participant Server as Headscale 서버
participant Client as 클라이언트 PC
participant Network as VPN 네트워크
Admin->>Server: 1. 사용자 생성
Admin->>Server: 2. PreAuth 키 생성
Admin->>Client: 3. PreAuth 키 전달
Client->>Client: 4. Tailscale 클라이언트 설치
Client->>Server: 5. 등록 요청 (PreAuth 키 포함)
Server->>Client: 6. 인증 완료 및 설정 전달
Client->>Network: 7. VPN 네트워크 참여
Network->>Client: 8. 다른 노드들과 P2P 연결
```
### 상세 단계별 설명
#### 1. 서버 측 작업 (관리자)
```bash
# 1-1. 사용자 생성 (한 번만)
docker exec headscale headscale users create pharmacy-busan
# 1-2. 사용자 목록 확인
docker exec headscale headscale users list
# 1-3. PreAuth 키 생성
docker exec headscale headscale preauthkeys create --user 1 --expiration 1h
# 출력 예시:
# f8d9c7e4b2a6c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4
```
#### 2. 클라이언트 측 작업
```bash
# 2-1. 기존 연결 해제 (있다면)
sudo tailscale logout
sudo tailscale down
# 2-2. Headscale 서버에 등록
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey=f8d9c7e4b2a6c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4 \
--hostname=pharmacy-busan-pc1 \
--accept-dns=false
# 2-3. 연결 상태 확인
tailscale status
# 2-4. IP 주소 확인
tailscale ip -4
```
#### 3. 결과 확인
```bash
# 서버에서 노드 목록 확인
docker exec headscale headscale nodes list
# 웹 UI에서 확인
# - Headplane: http://192.168.0.151:3000
# - FARMQ Admin: http://192.168.0.151:5001
```
---
## 🏥 실제 사용 시나리오
### 시나리오 1: 새 약국 등록
```bash
# 서버 작업 (관리자)
docker exec headscale headscale users create pharmacy-seoul
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 2h
# 클라이언트 작업 (약국 PC)
curl -O http://192.168.0.151:8000/add-client.sh
chmod +x add-client.sh
./add-client.sh pharmacy-seoul pos-terminal-1
# PreAuth 키 입력 시 위에서 생성한 키 사용
```
### 시나리오 2: 여러 PC가 있는 약국
```bash
# 같은 사용자로 여러 머신 등록 가능
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[KEY] --hostname=busan-pos1
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[KEY] --hostname=busan-pos2
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[KEY] --hostname=busan-office
```
### 시나리오 3: 임시 접속 (노트북)
```bash
# 짧은 만료시간으로 키 생성
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30m
# 노트북에서 임시 연결
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[TEMP_KEY] --hostname=manager-laptop
```
---
## 🔍 상태 확인 및 관리
### 명령어 모음
```bash
# === 서버 측 (Headscale) ===
# 사용자 관리
docker exec headscale headscale users list
docker exec headscale headscale users create [username]
# 노드 관리
docker exec headscale headscale nodes list
docker exec headscale headscale nodes expire [node_id]
# PreAuth 키 관리
docker exec headscale headscale preauthkeys list --user [user_id]
docker exec headscale headscale preauthkeys create --user [user_id] --expiration [time]
# === 클라이언트 측 (Tailscale) ===
# 상태 확인
tailscale status # 네트워크 상태 및 연결된 노드들
tailscale ip # 내 IP 주소들
tailscale netcheck # 네트워크 연결성 테스트
tailscale ping [node] # 특정 노드 ping
# 연결 관리
tailscale up # 연결 시작
tailscale down # 연결 중단
tailscale logout # 로그아웃
# 로그 확인
sudo journalctl -u tailscaled -f
```
### 웹 UI 접근
```bash
# Headplane (기본 관리 UI)
http://192.168.0.151:3000
# API 키: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI
# FARMQ 관리자 페이지 (한국어)
http://192.168.0.151:5001
```
---
## ⚠️ 중요한 주의사항
### 1. 포트 혼동 방지
- **클라이언트 등록**: `8070` 포트 사용
- **웹 관리**: `3000`, `5001` 포트 사용
- **Docker 내부**: `8080` 포트 (외부에서 직접 접근 불가)
### 2. PreAuth 키 보안
- 키는 일회용 또는 제한된 횟수만 사용 가능
- 짧은 만료시간 설정 권장 (1h ~ 24h)
- 키 노출 시 즉시 새 키 생성
### 3. 네트워크 구성
- 모든 클라이언트는 `100.64.0.0/10` 대역 IP 할당
- 첫 번째 클라이언트: `100.64.0.1` (서버 역할도 함)
- 이후 클라이언트들: `100.64.0.2`, `100.64.0.3`, ...
### 4. 방화벽 설정
```bash
# 서버 측 방화벽 (필요시)
sudo ufw allow 8070/tcp # Headscale
sudo ufw allow 3000/tcp # Headplane
sudo ufw allow 5001/tcp # FARMQ Admin
# 클라이언트 측 (Tailscale이 자동 처리)
sudo ufw allow in on tailscale0
```
---
## 🚨 문제 해결
### 일반적인 문제들
#### 1. "connection refused" 오류
```bash
# 원인: 서버 포트 접근 불가
# 해결:
docker-compose ps # 서버 실행 확인
sudo ufw status # 방화벽 확인
ping 192.168.0.151 # 서버 연결 확인
```
#### 2. "invalid auth key" 오류
```bash
# 원인: PreAuth 키 만료 또는 잘못된 키
# 해결:
docker exec headscale headscale preauthkeys create --user [user_id] --expiration 1h
```
#### 3. "user not found" 오류
```bash
# 원인: 존재하지 않는 사용자
# 해결:
docker exec headscale headscale users list # 사용자 확인
docker exec headscale headscale users create [username] # 사용자 생성
```
#### 4. IP 할당되지 않음
```bash
# 진단:
tailscale status # 연결 상태 확인
tailscale netcheck # 네트워크 테스트
sudo journalctl -u tailscaled -f # 로그 확인
# 해결:
sudo systemctl restart tailscaled # 서비스 재시작
```
### 로그 위치
```bash
# Headscale 서버 로그
docker logs headscale
# Headplane 로그
docker logs headplane
# Tailscale 클라이언트 로그
sudo journalctl -u tailscaled -f
```
---
## 📋 체크리스트
### 서버 설치 완료 확인
- [ ] Docker 및 Docker Compose 설치됨
- [ ] Headscale 컨테이너 실행 중 (포트 8070)
- [ ] Headplane 컨테이너 실행 중 (포트 3000)
- [ ] FARMQ Admin 실행 중 (포트 5001)
- [ ] 방화벽에서 필요 포트 열림
### 클라이언트 연결 확인
- [ ] Tailscale 클라이언트 설치됨
- [ ] 서버에 사용자 생성됨
- [ ] PreAuth 키 생성됨 (유효한 만료시간)
- [ ] `tailscale up` 명령어 성공
- [ ] `tailscale status`에서 다른 노드들 보임
- [ ] VPN IP 주소 할당됨 (`100.64.0.x`)
### 네트워크 연결 확인
- [ ] 서버 ping 성공 (`ping 100.64.0.1`)
- [ ] 다른 클라이언트와 ping 성공
- [ ] 웹 UI 접근 가능
---
## 🎯 다음 단계
1. **자동화 스크립트 활용**: `add-client.sh`, `create-preauth-key.sh` 사용
2. **모니터링 설정**: FARMQ Admin에서 실시간 상태 확인
3. **백업 전략**: Headscale 설정 및 데이터베이스 백업
4. **확장**: 새로운 약국 및 지점 추가
---
**🎊 이제 완전한 프라이빗 VPN 네트워크를 운영할 수 있습니다!**

View File

@ -0,0 +1,480 @@
# 🔑 FARMQ Headscale Pre-auth Key 관리 가이드
## 📚 목차
1. [Pre-auth Key 개념](#pre-auth-key-개념)
2. [키 유형별 비교](#키-유형별-비교)
3. [약국 환경별 사용 전략](#약국-환경별-사용-전략)
4. [실제 명령어 예시](#실제-명령어-예시)
5. [보안 관리](#보안-관리)
6. [문제 해결](#문제-해결)
7. [체크리스트](#체크리스트)
---
## 🧠 Pre-auth Key 개념
### Pre-auth Key란?
- **사전 인증 키**: 클라이언트가 Headscale 서버에 자동 등록할 수 있는 "입장권"
- **일회용 패스워드** 개념으로, 관리자가 미리 생성해서 배포
- **보안 계층**: 무작위 접속을 방지하는 첫 번째 보안 장벽
### 작동 원리
```mermaid
sequenceDiagram
participant Admin as 관리자
participant Server as Headscale 서버
participant Client as 클라이언트
Admin->>Server: 1. PreAuth 키 생성
Server-->>Admin: 2. 키 반환 (abc123def456...)
Admin->>Client: 3. 키 전달
Client->>Server: 4. 키와 함께 등록 요청
Server->>Server: 5. 키 검증
Server-->>Client: 6. 승인 및 VPN 설정 전송
Server->>Server: 7. 키 사용됨 표시 (일회용인 경우)
```
---
## 🔄 키 유형별 비교
### 1. 일회용 키 (Single-use Key)
```bash
# 생성 명령어
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 1h
# 특징
✅ 최고 수준 보안
✅ 정확한 기기 추적 가능
❌ 매번 새 키 생성 필요
❌ 관리 복잡도 높음
```
### 2. 재사용 키 (Reusable Key)
```bash
# 생성 명령어
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 7d --reusable
# 특징
✅ 편리한 관리
✅ 여러 기기에서 동일 키 사용
⚠️ 키 노출 시 보안 위험
⚠️ 기기별 구분 어려움
```
### 3. 임시 키 (Ephemeral Key)
```bash
# 생성 명령어
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30m --ephemeral
# 특징
✅ 일시적 접속용 최적
✅ 네트워크에서 자동 제거
❌ 영구 연결 불가
❌ 재시작 시 재등록 필요
```
---
## 🏥 약국 환경별 사용 전략
### 전략 1: 약국별 개별 키 (🌟 권장)
#### 적용 대상
- 정기적으로 운영되는 약국
- 여러 POS 단말기가 있는 매장
- 보안이 중요한 환경
#### 설정 예시
```bash
# 1단계: 약국별 사용자 생성
docker exec headscale headscale users create pharmacy-gangnam
docker exec headscale headscale users create pharmacy-hongdae
docker exec headscale headscale users create pharmacy-itaewon
# 2단계: 사용자 ID 확인
docker exec headscale headscale users list
# 출력:
# ID | Name
# 1 | myuser
# 2 | pharmacy-gangnam
# 3 | pharmacy-hongdae
# 4 | pharmacy-itaewon
# 3단계: 약국별 재사용 키 생성
docker exec headscale headscale preauthkeys create --user 2 --expiration 30d --reusable
# 강남약국용: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0
docker exec headscale headscale preauthkeys create --user 3 --expiration 30d --reusable
# 홍대약국용: z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4j3i2h1g0f9e8d7c6b5a4z3y2x1w0
docker exec headscale headscale preauthkeys create --user 4 --expiration 30d --reusable
# 이태원약국용: m5n6o7p8q9r0s1t2u3v4w5x6y7z8a9b0c1d2e3f4g5h6i7j8k9l0m1n2o3p4
```
#### 장점
- ✅ **약국별 구분**: 네트워크에서 약국별로 명확히 구분
- ✅ **부분적 보안**: 한 약국의 키 노출이 다른 약국에 영향 없음
- ✅ **관리 용이**: 약국별로 키 갱신 및 관리 가능
- ✅ **확장성**: 새 약국 추가 시 독립적으로 관리
### 전략 2: 지역별 그룹 키
#### 적용 대상
- 같은 지역 내 여러 지점
- 관리 구역별 분할 필요 시
- 중간 규모 보안 요구사항
```bash
# 지역별 사용자 생성
docker exec headscale headscale users create region-seoul
docker exec headscale headscale users create region-busan
docker exec headscale headscale users create region-daegu
# 지역별 키 생성 (서울 지역 모든 약국이 공유)
docker exec headscale headscale preauthkeys create --user 2 --expiration 14d --reusable
```
### 전략 3: 단일 공통 키 (⚠️ 비권장)
#### 적용 대상
- 테스트 환경
- 매우 소규모 운영 (5개 미만 약국)
- 관리 리소스 극도로 제한적인 경우
```bash
# 모든 약국이 하나의 키 공유
docker exec headscale headscale preauthkeys create --user 1 --expiration 90d --reusable
```
#### 단점
- ❌ **보안 위험**: 키 하나만 노출되면 전체 네트워크 위험
- ❌ **관리 복잡**: 문제 발생 시 원인 추적 어려움
- ❌ **확장성 부족**: 규모 증가 시 관리 한계
---
## 💻 실제 명령어 예시
### FARMQ 표준 설정 (권장)
#### 1단계: 약국 등록 준비
```bash
# 새 약국 등록 시 실행할 명령어들
# 약국명 변수 설정 (편의를 위해)
PHARMACY_NAME="pharmacy-myeongdong"
EXPIRATION="30d" # 30일 만료
echo "🏥 새 약국 등록: $PHARMACY_NAME"
```
#### 2단계: 사용자 생성
```bash
# 사용자 생성
docker exec headscale headscale users create "$PHARMACY_NAME"
# 생성 결과 확인
docker exec headscale headscale users list
```
#### 3단계: 사용자 ID 확인
```bash
# 방법 1: 수동 확인
docker exec headscale headscale users list | grep "$PHARMACY_NAME"
# 방법 2: 자동 추출 (스크립트용)
USER_ID=$(docker exec headscale headscale users list | grep "$PHARMACY_NAME" | awk '{print $1}')
echo "사용자 ID: $USER_ID"
```
#### 4단계: Pre-auth 키 생성
```bash
# 재사용 가능한 키 생성
PREAUTH_KEY=$(docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration "$EXPIRATION" --reusable | tail -1)
echo "🔑 생성된 Pre-auth Key:"
echo "$PREAUTH_KEY"
```
#### 5단계: 클라이언트에서 사용
```bash
# 약국의 각 기기에서 실행
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey="$PREAUTH_KEY" \
--hostname=myeongdong-pos1 \
--accept-dns=false
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey="$PREAUTH_KEY" \
--hostname=myeongdong-pos2 \
--accept-dns=false
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey="$PREAUTH_KEY" \
--hostname=myeongdong-office \
--accept-dns=false
```
### 특수 상황별 명령어
#### 임시 접속 (매니저 노트북)
```bash
# 2시간 짜리 일회용 키
docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 2h
# 일시적 접속 (재부팅 시 자동 해제)
docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 1h --ephemeral
```
#### 기술 지원용 (원격 지원)
```bash
# 30분 짜리 ephemeral 키 (지원 완료 후 자동 삭제)
docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 30m --ephemeral
```
#### 테스트용 (개발/검증)
```bash
# 테스트 사용자 및 짧은 만료시간
docker exec headscale headscale users create test-environment
docker exec headscale headscale preauthkeys create --user [TEST_USER_ID] --expiration 15m --reusable
```
---
## 🔐 보안 관리
### 키 생명주기 관리
#### 1. 키 생성 정책
```bash
# 권장 만료시간 설정
# - 일반 약국: 30일
# - 임시 접속: 2-8시간
# - 기술 지원: 30분-1시간
# - 테스트: 15분-1시간
# 예시: 단계별 만료시간
docker exec headscale headscale preauthkeys create --user 2 --expiration 30d --reusable # 운영
docker exec headscale headscale preauthkeys create --user 2 --expiration 4h # 임시
docker exec headscale headscale preauthkeys create --user 2 --expiration 30m --ephemeral # 지원
```
#### 2. 키 갱신 스케줄
```bash
# 월별 키 갱신 스크립트 (cron 등록 권장)
#!/bin/bash
# monthly-key-renewal.sh
PHARMACIES=("pharmacy-gangnam" "pharmacy-hongdae" "pharmacy-itaewon")
for pharmacy in "${PHARMACIES[@]}"; do
echo "🔄 갱신 중: $pharmacy"
# 기존 키 만료 처리 (수동)
echo "⚠️ 기존 키를 수동으로 비활성화하세요"
# 새 키 생성
USER_ID=$(docker exec headscale headscale users list | grep "$pharmacy" | awk '{print $1}')
NEW_KEY=$(docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 30d --reusable | tail -1)
echo "🔑 $pharmacy 새 키: $NEW_KEY"
echo "📧 약국에 새 키 전달 필요"
done
```
#### 3. 키 모니터링
```bash
# 활성 키 확인
docker exec headscale headscale preauthkeys list --user [USER_ID]
# 만료 예정 키 확인 (스크립트화 권장)
docker exec headscale headscale preauthkeys list --user [USER_ID] | grep -E "(expires|expired)"
```
### 보안 사고 대응
#### 키 노출 시 대응 절차
```bash
# 1단계: 즉시 새 키 생성
EMERGENCY_KEY=$(docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 7d --reusable | tail -1)
# 2단계: 해당 약국에 긴급 연락
echo "🚨 긴급 키 교체 필요"
echo "새 키: $EMERGENCY_KEY"
# 3단계: 기존 키로 등록된 노드 확인
docker exec headscale headscale nodes list --user [USER_ID]
# 4단계: 의심스러운 노드 제거 (필요시)
# docker exec headscale headscale nodes delete [NODE_ID]
```
### 접근 제한 설정
#### 태그 기반 접근 제어 (고급)
```bash
# 약국별 태그 설정
docker exec headscale headscale preauthkeys create \
--user [USER_ID] \
--expiration 30d \
--reusable \
--tags "pharmacy:gangnam,role:pos"
# 지역별 접근 제한
docker exec headscale headscale preauthkeys create \
--user [USER_ID] \
--expiration 30d \
--reusable \
--tags "region:seoul,type:retail"
```
---
## 🔧 문제 해결
### 일반적인 문제들
#### 1. "invalid auth key" 오류
```bash
# 원인: 키 만료, 잘못된 키, 이미 사용된 일회용 키
# 진단:
docker exec headscale headscale preauthkeys list --user [USER_ID]
# 해결: 새 키 생성
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 1h --reusable
```
#### 2. "user not found" 오류
```bash
# 원인: 존재하지 않는 사용자 ID
# 진단:
docker exec headscale headscale users list
# 해결: 사용자 생성
docker exec headscale headscale users create [USERNAME]
```
#### 3. "foreign key constraint" 오류
```bash
# 원인: 데이터베이스 무결성 문제 (FARMQ 확장 테이블과 충돌)
# 해결: 기존 사용자 사용 또는 데이터베이스 정리
docker exec headscale headscale users list # 기존 사용자 확인
# 기존 사용자 ID로 키 생성
```
### 디버깅 명령어
```bash
# 전체 키 목록 확인
docker exec headscale headscale preauthkeys list
# 특정 사용자의 키 목록
docker exec headscale headscale preauthkeys list --user [USER_ID]
# 노드 등록 상태 확인
docker exec headscale headscale nodes list
# 로그 확인
docker logs headscale | grep -i "preauth\|auth\|key"
```
---
## 📋 체크리스트
### 새 약국 등록 체크리스트
- [ ] 약국명 결정 (naming convention 준수)
- [ ] Headscale 사용자 생성
- [ ] 사용자 ID 확인
- [ ] 적절한 만료시간으로 Pre-auth 키 생성
- [ ] 키를 안전한 방법으로 약국에 전달
- [ ] 약국에서 클라이언트 등록 테스트
- [ ] 네트워크 연결 확인
- [ ] FARMQ 관리자 페이지에서 확인
### 정기 보안 점검 체크리스트
- [ ] 만료 예정 키 확인 (30일 전 알림)
- [ ] 사용되지 않는 키 정리
- [ ] 의심스러운 노드 연결 확인
- [ ] 키 사용 로그 검토
- [ ] 백업된 키 정보 업데이트
### 긴급 상황 대응 체크리스트
- [ ] 키 노출 확인 시 즉시 새 키 생성
- [ ] 해당 약국에 긴급 연락
- [ ] 의심스러운 노드 차단
- [ ] 사고 경위 문서화
- [ ] 재발 방지 대책 수립
---
## 📚 명령어 참조 카드
### 자주 사용하는 명령어
```bash
# === 사용자 관리 ===
docker exec headscale headscale users create [USERNAME]
docker exec headscale headscale users list
# === 키 생성 ===
# 일회용
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 1h
# 재사용 (일반적)
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30d --reusable
# 임시 (ephemeral)
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30m --ephemeral
# === 키 관리 ===
docker exec headscale headscale preauthkeys list --user [USER_ID]
docker exec headscale headscale preauthkeys expire [KEY_ID]
# === 노드 관리 ===
docker exec headscale headscale nodes list
docker exec headscale headscale nodes list --user [USER_ID]
docker exec headscale headscale nodes delete [NODE_ID]
```
### 클라이언트 명령어
```bash
# 표준 등록
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey=[PREAUTH_KEY] \
--hostname=[HOSTNAME] \
--accept-dns=false
# 상태 확인
tailscale status
tailscale ip -4
# 연결 해제
sudo tailscale down
sudo tailscale logout
```
---
## 🎯 모범 사례 요약
### DO ✅
- **약국별 개별 키 사용**
- **적절한 만료시간 설정** (30일 권장)
- **정기적인 키 갱신**
- **키 전달 시 보안 채널 사용**
- **키 사용 로그 모니터링**
### DON'T ❌
- **모든 약국이 하나의 키 공유하지 않기**
- **만료시간 너무 길게 설정하지 않기** (90일 이상)
- **키를 평문으로 이메일 전송하지 않기**
- **만료된 키 방치하지 않기**
- **키 백업 없이 운영하지 않기**
---
**🎊 체계적인 키 관리로 안전한 FARMQ 네트워크를 운영하세요!**

View File

@ -0,0 +1,376 @@
# 팜큐(FARMQ) Proxmox VNC 통합 시스템 기획서
## 🎯 프로젝트 개요
### 목표
Flask Admin 웹 인터페이스에서 **한 번의 클릭**으로 Proxmox VM의 VNC 화면에 접속할 수 있는 통합 시스템 구축
### 핵심 아이디어
1. **Headscale 네트워크**를 통해 모든 약국의 Proxmox 호스트에 접근 가능
2. **Proxmox VNC API**를 활용하여 VM 화면 원격 제어
3. **브라우저 기반 VNC 클라이언트** (Guacamole/noVNC)로 즉시 접속
4. **Flask Admin 버튼****VNC 화면** 원클릭 연결
## 🏗️ 시스템 아키텍처
```
[Flask Admin Dashboard]
↓ (클릭)
[VNC 연결 요청 API]
[Headscale 네트워크]
↓ (100.64.0.x)
[Proxmox Host Server]
↓ (VNC API)
[VM VNC Console]
[noVNC/Guacamole Web Client]
[사용자 브라우저]
```
## 📋 기술 스택
### Frontend
- **noVNC**: HTML5 VNC 클라이언트 (가벼움, 쉬운 통합)
- **Apache Guacamole**: 더 고급 기능 (클립보드, 파일 전송)
- **Bootstrap 5**: UI 프레임워크
### Backend
- **Flask**: 웹 서버 및 API
- **Proxmox VE API**: VM 관리 및 VNC 토큰 생성
- **WebSocket Proxy**: VNC 트래픽 중계
### Network
- **Headscale**: 팜큐 네트워크 (100.64.0.0/10)
- **Tailscale**: 각 Proxmox 호스트 연결
## 🔧 구현 단계
### Phase 1: Proxmox API 통합 (1-2일)
#### 1.1 Proxmox API 클라이언트 구현
```python
# utils/proxmox_client.py
class ProxmoxClient:
def __init__(self, host, username, password):
self.host = host # 100.64.0.x (Headscale IP)
def get_vm_list(self):
"""VM 목록 조회"""
def get_vnc_ticket(self, vmid):
"""VNC 접속 티켓 생성"""
def get_vm_status(self, vmid):
"""VM 상태 확인"""
```
#### 1.2 데이터베이스 모델 확장
```python
# models/farmq_models.py
class ProxmoxVM(FarmqBase):
__tablename__ = 'proxmox_vms'
id = Column(Integer, primary_key=True)
pharmacy_id = Column(Integer, ForeignKey('pharmacy_info.id'))
proxmox_host_ip = Column(String(15)) # 100.64.0.x
vmid = Column(Integer)
vm_name = Column(String(100))
vm_type = Column(String(50)) # windows, linux
status = Column(String(20)) # running, stopped
cpu_cores = Column(Integer)
memory_mb = Column(Integer)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
```
### Phase 2: VNC 웹 클라이언트 구현 (2-3일)
#### 2.1 noVNC 통합 (권장)
```html
<!-- templates/vnc/novnc.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{ vm_name }} - VNC Console</title>
<script src="/static/novnc/vnc.js"></script>
</head>
<body>
<div id="vnc-container">
<canvas id="vnc-canvas"></canvas>
</div>
<script>
const rfb = new RFB(document.getElementById('vnc-canvas'),
'ws://{{ flask_server }}/vnc/{{ session_id }}');
</script>
</body>
</html>
```
#### 2.2 WebSocket Proxy 서버
```python
# vnc_proxy.py
import websockets
import asyncio
class VNCProxy:
async def proxy_vnc_connection(self, websocket, path):
# Flask → Proxmox VNC 연결 중계
session_id = extract_session_id(path)
proxmox_vnc = await connect_to_proxmox_vnc(session_id)
# 양방향 데이터 중계
await asyncio.gather(
relay_websocket_to_vnc(websocket, proxmox_vnc),
relay_vnc_to_websocket(proxmox_vnc, websocket)
)
```
### Phase 3: Flask Admin 통합 (1일)
#### 3.1 VNC 연결 API 엔드포인트
```python
# app.py
@app.route('/api/vm/<int:vm_id>/vnc', methods=['POST'])
def connect_vm_vnc(vm_id):
"""VM VNC 연결 세션 생성"""
try:
vm = get_vm_by_id(vm_id)
proxmox = ProxmoxClient(vm.proxmox_host_ip, username, password)
# VNC 티켓 생성
vnc_ticket = proxmox.get_vnc_ticket(vm.vmid)
# 세션 생성
session_id = create_vnc_session(vm_id, vnc_ticket)
return jsonify({
'session_id': session_id,
'vnc_url': f'/vnc/console/{session_id}',
'vm_info': vm.to_dict()
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/vnc/console/<session_id>')
def vnc_console(session_id):
"""VNC 콘솔 페이지"""
session = get_vnc_session(session_id)
return render_template('vnc/novnc.html',
session=session,
vm_name=session['vm_name'])
```
#### 3.2 약국 관리 페이지 업데이트
```html
<!-- templates/pharmacy/detail.html -->
<div class="card">
<div class="card-header">
<h5>가상 머신 목록</h5>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>VM 이름</th>
<th>상태</th>
<th>타입</th>
<th>리소스</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for vm in pharmacy_vms %}
<tr>
<td>{{ vm.vm_name }}</td>
<td>
{% if vm.status == 'running' %}
<span class="badge bg-success">실행 중</span>
{% else %}
<span class="badge bg-secondary">정지</span>
{% endif %}
</td>
<td>{{ vm.vm_type }}</td>
<td>{{ vm.cpu_cores }}C / {{ vm.memory_mb }}MB</td>
<td>
{% if vm.status == 'running' %}
<button class="btn btn-primary btn-sm"
onclick="openVNC({{ vm.id }})">
<i class="fas fa-desktop"></i> VNC 접속
</button>
{% endif %}
<button class="btn btn-info btn-sm"
onclick="showVMDetails({{ vm.id }})">
<i class="fas fa-info-circle"></i> 상세
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
async function openVNC(vmId) {
try {
showSpinner('VNC 연결 준비 중...');
const response = await fetch(`/api/vm/${vmId}/vnc`, {
method: 'POST'
});
const data = await response.json();
if (response.ok) {
// 새 탭에서 VNC 콘솔 열기
window.open(data.vnc_url, '_blank',
'width=1024,height=768,scrollbars=yes,resizable=yes');
} else {
showToast(data.error, 'error');
}
} catch (error) {
showToast('VNC 연결 실패: ' + error.message, 'error');
} finally {
hideSpinner();
}
}
</script>
```
## 📊 데이터 흐름
### 1. VM 목록 동기화
```
Proxmox API → Flask Backend → Database → Admin Dashboard
```
### 2. VNC 연결 프로세스
```
1. 사용자가 "VNC 접속" 버튼 클릭
2. Flask API가 Proxmox API 호출하여 VNC 티켓 생성
3. WebSocket 프록시 세션 생성
4. 새 브라우저 탭에서 noVNC 클라이언트 실행
5. 실시간 VM 화면 표시
```
## 🔐 보안 고려사항
### 인증 및 권한
```python
# 약국별 VM 접근 권한 검증
def check_vm_access_permission(user_id, vm_id):
"""사용자가 해당 VM에 접근 권한이 있는지 확인"""
user_pharmacy = get_user_pharmacy(user_id)
vm_pharmacy = get_vm_pharmacy(vm_id)
return user_pharmacy.id == vm_pharmacy.id
# VNC 세션 시간 제한
VNC_SESSION_TIMEOUT = 3600 # 1시간
```
### 네트워크 보안
- **Headscale 네트워크 내부**에서만 Proxmox 접근
- **HTTPS/WSS** 암호화 통신
- **세션 기반** 일회성 VNC 토큰
## 🎨 UI/UX 설계
### 메인 대시보드
```
┌─────────────────────────────────────────┐
│ 📊 팜큐 관리 대시보드 │
├─────────────────────────────────────────┤
│ 약국: 세종온누리약국 │
│ ┌─────────┬──────────┬────────┬─────────┐ │
│ │ VM 이름 │ 상태 │ 타입 │ 액션 │ │
│ ├─────────┼──────────┼────────┼─────────┤ │
│ │ POS-01 │ 🟢 실행중 │ Win11 │ [VNC접속]│ │
│ │ SERVER │ 🟢 실행중 │ Ubuntu │ [VNC접속]│ │
│ │ BACKUP │ ⚪ 정지 │ Win10 │ [시작] │ │
│ └─────────┴──────────┴────────┴─────────┘ │
└─────────────────────────────────────────┘
```
### VNC 콘솔 화면
```
┌─────────────────────────────────────────┐
│ 🖥️ POS-01 (Windows 11) - VNC Console │
├─────────────────────────────────────────┤
│ [전체화면] [클립보드] [Ctrl+Alt+Del] │
├─────────────────────────────────────────┤
│ │
│ VM 화면이 여기에 표시 │
│ (noVNC Canvas) │
│ │
└─────────────────────────────────────────┘
```
## 📋 구현 체크리스트
### Backend (Flask)
- [ ] Proxmox API 클라이언트 구현
- [ ] ProxmoxVM 데이터 모델 생성
- [ ] VNC 세션 관리 시스템
- [ ] WebSocket 프록시 서버
- [ ] API 엔드포인트 구현
### Frontend (Templates)
- [ ] noVNC 라이브러리 통합
- [ ] VNC 콘솔 페이지 템플릿
- [ ] 약국 상세 페이지 VM 섹션
- [ ] JavaScript VNC 연결 함수
### 시스템 통합
- [ ] VM 목록 자동 동기화
- [ ] 권한 검증 시스템
- [ ] 에러 처리 및 로깅
- [ ] 성능 최적화
## 🚀 배포 계획
### 개발 환경 테스트
1. **로컬 Proxmox 테스트**: VirtualBox/VMware로 Proxmox VE 설치
2. **noVNC 연동 테스트**: 기본 VNC 연결 확인
3. **Headscale 네트워크 테스트**: 원격 Proxmox 접근
### 운영 환경 적용
1. **점진적 배포**: 1개 약국부터 테스트
2. **모니터링 시스템**: VNC 연결 로그 및 성능 측정
3. **백업 접근 방법**: VNC 실패 시 SSH/RDP 대안
## 💡 추가 기능 아이디어
### Phase 2 고급 기능
- **다중 모니터 지원**: VM이 여러 화면을 사용하는 경우
- **클립보드 공유**: 로컬 PC ↔ 원격 VM 텍스트 복사
- **파일 전송**: 드래그앤드롭 파일 업로드
- **스크린샷 캡처**: 문제 해결을 위한 화면 저장
- **세션 녹화**: 작업 과정 기록
### 모니터링 및 분석
- **VNC 사용 통계**: 접속 시간, 빈도 분석
- **VM 성능 모니터링**: CPU, 메모리 사용률 실시간 표시
- **접속 이력 관리**: 언제, 누가, 어떤 VM에 접속했는지 로그
## 🎯 예상 효과
### 업무 효율성
- **즉시 원격 지원**: 약국 직원 도움 요청 시 바로 화면 접속
- **중앙 집중 관리**: 모든 약국 VM을 한 곳에서 관리
- **문제 해결 시간 단축**: 전화 설명 → 직접 화면 제어
### 기술적 장점
- **Headscale 네트워크 활용**: 기존 인프라 최대한 활용
- **브라우저 기반**: 별도 소프트웨어 설치 불필요
- **확장성**: 새로운 약국 추가 시 자동 연동
---
## 📞 문의 및 지원
이 시스템 구현 과정에서 기술적 이슈나 추가 요구사항이 있으면 언제든 문의하세요.
**핵심 목표**: 🖱️ **원클릭** → 🖥️ **VM 화면 접속**

297
PharmQ-SaaS-Service-Plan.md Normal file
View File

@ -0,0 +1,297 @@
# PharmQ SaaS 구독 서비스 관리 시스템 기획서
## 1. 프로젝트 개요
### 1.1 목적
- PharmQ가 제공하는 다양한 서비스에 대한 약국별 구독 관리
- SaaS 형태의 과금 서비스 기반 마련
- 약국별 서비스 이용 현황 실시간 모니터링
### 1.2 서비스 라인업
1. **클라우드 PC** (Proxmox 기반 가상 데스크톱)
2. **AI CCTV** (인공지능 기반 보안 모니터링)
3. **CRM** (고객 관계 관리 시스템)
## 2. 데이터베이스 스키마 설계
### 2.1 새로 추가할 테이블
#### 2.1.1 `service_products` - 서비스 상품 마스터
```sql
CREATE TABLE service_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code VARCHAR(20) UNIQUE NOT NULL, -- 'CLOUD_PC', 'AI_CCTV', 'CRM'
product_name VARCHAR(100) NOT NULL, -- '클라우드 PC', 'AI CCTV', 'CRM'
description TEXT, -- 서비스 상세 설명
monthly_price DECIMAL(10,2) NOT NULL, -- 월 구독료
setup_fee DECIMAL(10,2) DEFAULT 0, -- 초기 설치비
is_active BOOLEAN DEFAULT TRUE, -- 서비스 활성화 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### 2.1.2 `pharmacy_subscriptions` - 약국별 구독 현황
```sql
CREATE TABLE pharmacy_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL, -- pharmacy_info.id 참조
product_id INTEGER NOT NULL, -- service_products.id 참조
subscription_status VARCHAR(20) NOT NULL, -- 'ACTIVE', 'SUSPENDED', 'CANCELLED'
start_date DATE NOT NULL, -- 구독 시작일
end_date DATE, -- 구독 종료일 (NULL이면 무제한)
next_billing_date DATE, -- 다음 결제일
monthly_fee DECIMAL(10,2) NOT NULL, -- 실제 적용 월 구독료
notes TEXT, -- 특이사항
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacy_info(id),
FOREIGN KEY (product_id) REFERENCES service_products(id),
UNIQUE(pharmacy_id, product_id) -- 약국-상품당 하나의 구독만
);
```
#### 2.1.3 `subscription_usage_logs` - 서비스 이용 로그
```sql
CREATE TABLE subscription_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL, -- pharmacy_subscriptions.id 참조
usage_type VARCHAR(50) NOT NULL, -- 'LOGIN', 'API_CALL', 'STORAGE_USE' 등
usage_amount INTEGER DEFAULT 1, -- 사용량 (로그인 횟수, API 호출 수 등)
usage_date DATE NOT NULL, -- 사용일
metadata JSON, -- 추가 메타데이터
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
);
```
#### 2.1.4 `billing_history` - 결제 이력
```sql
CREATE TABLE billing_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL, -- pharmacy_subscriptions.id 참조
billing_period_start DATE NOT NULL, -- 과금 기간 시작
billing_period_end DATE NOT NULL, -- 과금 기간 종료
amount DECIMAL(10,2) NOT NULL, -- 청구 금액
billing_status VARCHAR(20) NOT NULL, -- 'PENDING', 'PAID', 'OVERDUE', 'CANCELLED'
billing_date DATE, -- 실제 결제일
payment_method VARCHAR(50), -- 결제 수단
invoice_number VARCHAR(100), -- 청구서 번호
notes TEXT, -- 결제 관련 메모
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
);
```
### 2.2 기존 테이블 연동
- `pharmacy_info` 테이블과 `pharmacy_subscriptions` 테이블을 연결
- 사용자 관리에서 약국별 구독 서비스 현황 표시
## 3. 프론트엔드 UI/UX 설계
### 3.1 대시보드 개선
#### 3.1.1 메인 대시보드 (`/`)
- **구독 서비스 현황 카드** 추가
```
┌─────────────────────────────────────────┐
│ 📊 구독 서비스 현황 │
├─────────────────────────────────────────┤
│ 클라우드 PC │ 12/14 약국 (85.7%) │
│ AI CCTV │ 8/14 약국 (57.1%) │
│ CRM │ 10/14 약국 (71.4%) │
├─────────────────────────────────────────┤
│ 총 월 매출 │ ₩2,450,000 │
└─────────────────────────────────────────┘
```
#### 3.1.2 서비스별 상태 인디케이터
- 각 서비스별 색상 코드 적용
- 🟢 클라우드 PC (녹색)
- 🔵 AI CCTV (파란색)
- 🟡 CRM (노란색)
### 3.2 약국 관리 페이지 개선 (`/pharmacy`)
#### 3.2.1 약국 목록에 구독 상태 표시
```
┌─────────────────────────────────────────────────────────────────┐
│ 약국명 │ 위치 │ 구독 서비스 │ 월 구독료 │ 액션 │
├─────────────────────────────────────────────────────────────────┤
│ 서울약국 │ 서울 강남구 │ 🟢 💻 🔵 📷 🟡 📊 │ ₩180,000 │ ⚙️ │
│ 부산약국 │ 부산 해운대 │ 🟢 💻 🔵 📷 │ ₩120,000 │ ⚙️ │
│ 대구약국 │ 대구 중구 │ 🟢 💻 │ ₩60,000 │ ⚙️ │
└─────────────────────────────────────────────────────────────────┘
```
#### 3.2.2 약국 상세 페이지 구독 탭 추가
- 기존: 기본정보, 네트워크정보, 연결된 머신
- **신규**: **구독 서비스** 탭 추가
```
┌─────────────────────────────────────────────────────────────────┐
│ [기본정보] [네트워크정보] [연결된 머신] [구독 서비스] ←← 신규 탭 │
├─────────────────────────────────────────────────────────────────┤
│ 📦 구독 중인 서비스 │
│ │
│ 🟢 클라우드 PC ₩60,000/월 [관리] │
│ ├─ 구독기간: 2024.01.15 ~ 무제한 │
│ ├─ 다음결제: 2025.10.15 │
│ └─ 상태: 정상 (ACTIVE) │
│ │
│ 🔵 AI CCTV ₩80,000/월 [관리] │
│ ├─ 구독기간: 2024.03.01 ~ 무제한 │
│ ├─ 다음결제: 2025.10.01 │
│ └─ 상태: 정상 (ACTIVE) │
│ │
│ 📦 구독 가능한 서비스 │
│ │
│ 🟡 CRM 시스템 ₩40,000/월 [가입] │
│ └─ 고객 관계 관리 및 매출 분석 도구 │
│ │
│ [+ 새 서비스 구독하기] │
└─────────────────────────────────────────────────────────────────┘
```
### 3.3 새로운 메뉴 추가
#### 3.3.1 사이드바 메뉴 구조 개선
```
PharmQ Super Admin (PSA)
├── 📊 대시보드
├── 🏥 약국 관리
├── 👥 PQON 사용자 관리
├── 💻 머신 관리
├── 🖥️ VM 관리 (VNC)
├── 📦 구독 서비스 관리 ←← 신규 메뉴
│ ├── 구독 현황 조회
│ ├── 서비스 상품 관리
│ ├── 결제 이력 조회
│ └── 사용량 통계
├── 📈 매출 대시보드 ←← 신규 메뉴
└── 🔗 Medivault
```
#### 3.3.2 구독 서비스 관리 페이지 (`/subscriptions`)
- **전체 구독 현황 테이블**
- **서비스별 필터링**
- **구독 상태별 필터링** (활성/일시정지/해지)
- **월별 매출 차트**
#### 3.3.3 매출 대시보드 (`/revenue`)
- **월별 매출 트렌드**
- **서비스별 매출 비중**
- **약국별 구독료 순위**
- **신규 구독/해지 통계**
## 4. API 설계
### 4.1 구독 관리 API
#### 4.1.1 구독 현황 조회
```
GET /api/subscriptions
GET /api/subscriptions/pharmacy/{pharmacy_id}
GET /api/subscriptions/product/{product_code}
```
#### 4.1.2 구독 생성/수정
```
POST /api/subscriptions # 새 구독 생성
PUT /api/subscriptions/{subscription_id} # 구독 정보 수정
DELETE /api/subscriptions/{subscription_id} # 구독 해지
```
#### 4.1.3 서비스 상품 관리
```
GET /api/products # 상품 목록
POST /api/products # 상품 등록
PUT /api/products/{product_id} # 상품 수정
```
### 4.2 결제 및 통계 API
#### 4.2.1 결제 관련
```
GET /api/billing/history/{subscription_id} # 결제 이력
POST /api/billing/invoice # 청구서 생성
PUT /api/billing/{billing_id}/status # 결제 상태 업데이트
```
#### 4.2.2 통계 및 리포트
```
GET /api/analytics/revenue/monthly # 월별 매출
GET /api/analytics/subscriptions/summary # 구독 요약
GET /api/analytics/usage/{subscription_id} # 서비스 사용량
```
## 5. 구현 단계별 로드맵
### Phase 1: 기본 구조 구축 (1-2주)
- [x] 데이터베이스 스키마 생성
- [ ] 기본 API 엔드포인트 구현
- [ ] 대시보드 구독 현황 카드 추가
### Phase 2: 약국별 구독 관리 (2-3주)
- [ ] 약국 상세 페이지 구독 탭 추가
- [ ] 구독 생성/수정/해지 기능
- [ ] 약국 목록 구독 상태 표시
### Phase 3: 서비스 관리 및 통계 (3-4주)
- [ ] 구독 서비스 관리 메뉴 구현
- [ ] 매출 대시보드 구현
- [ ] 사용량 로깅 시스템
### Phase 4: 과금 시스템 (4-6주)
- [ ] 자동 결제 시스템
- [ ] 청구서 생성
- [ ] 결제 연동 (포트원/토스페이먼츠 등)
## 6. 기술 스택
### 6.1 백엔드
- **Database**: SQLite (개발) → PostgreSQL (운영)
- **API**: Flask REST API
- **결제**: 포트원(PortOne) 또는 토스페이먼츠
### 6.2 프론트엔드
- **Framework**: Bootstrap 5 + Jinja2
- **Charts**: Chart.js 또는 D3.js
- **Icons**: Font Awesome
### 6.3 모니터링
- **사용량 추적**: Custom logging system
- **알림**: 결제 실패, 구독 만료 등
## 7. 보안 및 컴플라이언스
### 7.1 데이터 보안
- 결제 정보 암호화
- 개인정보 보호법 준수
- API 인증/인가 체계
### 7.2 백업 및 복구
- 데이터베이스 일일 백업
- 결제 데이터 별도 보관
- 장애 복구 프로세스
---
## 8. 예상 효과
### 8.1 비즈니스 효과
- **매출 가시화**: 실시간 구독 매출 현황 파악
- **고객 관리**: 약국별 서비스 이용 패턴 분석
- **확장성**: 새로운 서비스 추가 용이
### 8.2 운영 효율화
- **자동화**: 구독/해지/결제 프로세스 자동화
- **모니터링**: 서비스별 이용 현황 실시간 추적
- **리포팅**: 월별/분기별 매출 리포트 자동 생성
---
**작성일**: 2025년 9월 11일
**작성자**: PharmQ Development Team
**버전**: v1.0

196
add-client.sh Executable file
View File

@ -0,0 +1,196 @@
#!/bin/bash
# =============================================================================
# FARMQ Headscale 클라이언트 자동 등록 스크립트
# =============================================================================
# 사용법: ./add-client.sh [사용자명] [머신명]
# 예시: ./add-client.sh pharmacy-01 busan-store-pc
# =============================================================================
set -e # 오류 발생 시 스크립트 중단
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 로고
echo -e "${BLUE}"
echo " ███████╗ █████╗ ██████╗ ██╗ ██╗ ██████╗ "
echo " ██╔════╝██╔══██╗██╔══██╗████╗ ████║██╔═══██╗"
echo " █████╗ ███████║██████╔╝██╔████╔██║██║ ██║"
echo " ██╔══╝ ██╔══██║██╔══██╗██║╚██╔╝██║██║▄▄ ██║"
echo " ██║ ██║ ██║██║ ██║██║ ╚═╝ ██║╚██████╔╝"
echo " ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══▀▀═╝ "
echo -e "${NC}"
echo -e "${GREEN}🏥 FARMQ Headscale 클라이언트 자동 등록 스크립트${NC}"
echo "============================================================"
# 설정 변수
HEADSCALE_SERVER="192.168.0.151:8070"
HEADSCALE_URL="http://${HEADSCALE_SERVER}"
FARMQ_ADMIN_URL="http://192.168.0.151:5001"
# 파라미터 확인
USER_NAME=${1:-""}
MACHINE_NAME=${2:-""}
# 사용자 입력 받기
if [[ -z "$USER_NAME" ]]; then
echo -e "${YELLOW}📝 사용자명을 입력하세요 (예: pharmacy-01, store-busan):${NC}"
read -p "사용자명: " USER_NAME
fi
if [[ -z "$MACHINE_NAME" ]]; then
echo -e "${YELLOW}📝 머신명을 입력하세요 (예: pos-terminal, office-pc):${NC}"
read -p "머신명: " MACHINE_NAME
fi
# 입력값 검증
if [[ -z "$USER_NAME" ]] || [[ -z "$MACHINE_NAME" ]]; then
echo -e "${RED}❌ 사용자명과 머신명은 필수입니다.${NC}"
exit 1
fi
echo -e "${BLUE}📋 설정 정보:${NC}"
echo " 사용자명: $USER_NAME"
echo " 머신명: $MACHINE_NAME"
echo " Headscale 서버: $HEADSCALE_SERVER"
echo ""
# 확인 메시지
echo -e "${YELLOW}⚠️ 이 설정으로 진행하시겠습니까? (y/N)${NC}"
read -p "진행: " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo -e "${RED}❌ 취소되었습니다.${NC}"
exit 1
fi
echo -e "${GREEN}🚀 클라이언트 등록을 시작합니다...${NC}"
# 1. 시스템 업데이트
echo -e "${BLUE}📦 시스템 업데이트 중...${NC}"
sudo apt update -qq
# 2. Tailscale 설치 확인
if ! command -v tailscale &> /dev/null; then
echo -e "${YELLOW}📥 Tailscale 설치 중...${NC}"
curl -fsSL https://tailscale.com/install.sh | sh
else
echo -e "${GREEN}✅ Tailscale이 이미 설치되어 있습니다.${NC}"
fi
# 3. 기존 Tailscale 연결 해제 (있는 경우)
echo -e "${BLUE}🧹 기존 연결 정리 중...${NC}"
sudo tailscale logout 2>/dev/null || true
sudo tailscale down 2>/dev/null || true
# 4. Headscale 서버 연결 테스트
echo -e "${BLUE}🔍 Headscale 서버 연결 테스트 중...${NC}"
if ! curl -s --connect-timeout 5 "$HEADSCALE_URL/health" > /dev/null 2>&1; then
echo -e "${RED}❌ Headscale 서버에 연결할 수 없습니다: $HEADSCALE_URL${NC}"
echo -e "${YELLOW}💡 서버가 실행 중이고 방화벽 설정을 확인하세요.${NC}"
exit 1
fi
echo -e "${GREEN}✅ Headscale 서버 연결 성공${NC}"
# 5. Pre-auth key 생성 (서버에서 실행)
echo -e "${BLUE}🔑 Pre-auth key 생성 중...${NC}"
echo -e "${YELLOW}📝 Headscale 서버에서 다음 명령어를 실행해야 합니다:${NC}"
echo ""
echo -e "${GREEN}# SSH로 서버 접속 후 실행:${NC}"
echo "cd /srv/headscale-setup"
echo "docker exec headscale headscale users create $USER_NAME 2>/dev/null || true"
echo "docker exec headscale headscale preauthkeys create --user $USER_NAME --expiration 1h"
echo ""
# Pre-auth key 입력 받기
echo -e "${YELLOW}🔐 생성된 Pre-auth key를 입력하세요:${NC}"
read -p "Pre-auth key: " PREAUTH_KEY
if [[ -z "$PREAUTH_KEY" ]]; then
echo -e "${RED}❌ Pre-auth key는 필수입니다.${NC}"
exit 1
fi
# 6. Tailscale을 Headscale 서버에 연결
echo -e "${BLUE}🔗 Headscale 서버에 연결 중...${NC}"
# 머신명 설정
sudo tailscale up \
--login-server="$HEADSCALE_URL" \
--authkey="$PREAUTH_KEY" \
--hostname="$MACHINE_NAME" \
--accept-dns=false \
--reset
# 7. 연결 상태 확인
echo -e "${BLUE}🔍 연결 상태 확인 중...${NC}"
sleep 5
# Tailscale 상태 확인
if tailscale status >/dev/null 2>&1; then
echo -e "${GREEN}✅ Tailscale 서비스 정상 작동${NC}"
# IP 주소 확인
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "IP 조회 실패")
echo -e "${GREEN}📍 할당된 IP 주소: $TAILSCALE_IP${NC}"
# 연결된 노드 목록
echo -e "${BLUE}🌐 연결된 노드 목록:${NC}"
tailscale status
else
echo -e "${RED}❌ Tailscale 연결에 실패했습니다.${NC}"
echo -e "${YELLOW}💡 로그 확인: sudo journalctl -u tailscaled -f${NC}"
exit 1
fi
# 8. 네트워크 연결 테스트
echo -e "${BLUE}🧪 네트워크 연결 테스트 중...${NC}"
# 서버와의 연결 테스트
if ping -c 3 100.64.0.1 >/dev/null 2>&1; then
echo -e "${GREEN}✅ Headscale 서버와 통신 성공 (100.64.0.1)${NC}"
else
echo -e "${YELLOW}⚠️ 서버와의 직접 통신은 실패했지만 정상일 수 있습니다.${NC}"
fi
# 9. 시스템 서비스 활성화
echo -e "${BLUE}⚙️ 시스템 서비스 설정 중...${NC}"
sudo systemctl enable tailscaled
sudo systemctl start tailscaled
# 10. 방화벽 설정 (선택사항)
if command -v ufw &> /dev/null; then
echo -e "${BLUE}🔥 방화벽 설정 중...${NC}"
sudo ufw allow in on tailscale0 2>/dev/null || true
fi
# 11. 완료 메시지
echo -e "${GREEN}"
echo "🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉"
echo " FARMQ 클라이언트 등록 완료!"
echo "🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉"
echo -e "${NC}"
echo -e "${GREEN}✅ 클라이언트가 성공적으로 등록되었습니다!${NC}"
echo ""
echo -e "${BLUE}📊 연결 정보:${NC}"
echo " • 사용자명: $USER_NAME"
echo " • 머신명: $MACHINE_NAME"
echo " • Tailscale IP: $TAILSCALE_IP"
echo " • 서버 주소: $HEADSCALE_SERVER"
echo ""
echo -e "${BLUE}🌐 관리 페이지:${NC}"
echo " • FARMQ 관리자 페이지: $FARMQ_ADMIN_URL"
echo " • Headplane UI: http://192.168.0.151:3000"
echo ""
echo -e "${BLUE}🔧 유용한 명령어:${NC}"
echo " • 상태 확인: tailscale status"
echo " • IP 주소 확인: tailscale ip"
echo " • 로그 확인: sudo journalctl -u tailscaled -f"
echo " • 재시작: sudo systemctl restart tailscaled"
echo ""
echo -e "${GREEN}🎊 이제 FARMQ 네트워크의 일부가 되었습니다!${NC}"

74
clean-database.py Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
데이터베이스 정리 - 문제가 되는 테이블들 제거
"""
import sqlite3
import os
from datetime import datetime
def clean_database():
"""문제가 되는 테이블들 제거"""
db_path = '/srv/headscale-setup/data/db.sqlite'
backup_path = f'/srv/headscale-setup/data/db.sqlite.clean_backup.{datetime.now().strftime("%Y%m%d_%H%M%S")}'
print("🧹 데이터베이스 정리 - 문제 테이블 제거")
print("=" * 50)
# 백업 생성
print(f"📦 백업 생성: {backup_path}")
import shutil
shutil.copy2(db_path, backup_path)
# 데이터베이스 연결
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# 외래키 제약조건 비활성화
cursor.execute("PRAGMA foreign_keys = OFF")
# 문제가 되는 테이블들 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%pharmacy%'")
problem_tables = cursor.fetchall()
print(f"🎯 제거할 테이블들: {[table[0] for table in problem_tables]}")
# 테이블들 제거
for table in problem_tables:
table_name = table[0]
print(f"🗑️ 테이블 제거: {table_name}")
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
# monitoring_data, machine_specs 등도 제거 (필요시)
additional_tables = ['monitoring_data', 'machine_specs']
for table_name in additional_tables:
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}'")
if cursor.fetchone():
print(f"🗑️ 추가 테이블 제거: {table_name}")
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
# 변경사항 커밋
conn.commit()
# 남은 테이블 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
remaining_tables = cursor.fetchall()
print(f"✅ 남은 테이블들: {[table[0] for table in remaining_tables]}")
# 무결성 검사
cursor.execute("PRAGMA integrity_check")
integrity = cursor.fetchone()[0]
print(f"🔍 무결성 검사: {integrity}")
print("✅ 데이터베이스 정리 완료!")
print(f"📦 백업 위치: {backup_path}")
except Exception as e:
print(f"❌ 오류: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == '__main__':
clean_database()

View File

@ -825,6 +825,328 @@ def create_app(config_name=None):
'error': f'서버 오류: {str(e)}' 'error': f'서버 오류: {str(e)}'
}), 500 }), 500
# =================== 구독 서비스 관리 API ===================
@app.route('/api/subscriptions/stats', methods=['GET'])
def api_subscription_stats():
"""대시보드용 구독 현황 통계"""
try:
import sqlite3
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 서비스별 구독 현황
cursor.execute('''
SELECT
sp.product_code,
sp.product_name,
COUNT(ps.id) as subscription_count,
SUM(ps.monthly_fee) as total_monthly_revenue
FROM service_products sp
LEFT JOIN pharmacy_subscriptions ps ON sp.id = ps.product_id
AND ps.subscription_status = 'ACTIVE'
GROUP BY sp.id, sp.product_code, sp.product_name
ORDER BY sp.product_code
''')
services = []
total_revenue = 0
total_subscriptions = 0
for row in cursor.fetchall():
product_code, product_name, count, revenue = row
revenue = revenue or 0
services.append({
'code': product_code,
'name': product_name,
'count': count,
'revenue': revenue
})
total_revenue += revenue
total_subscriptions += count
# 전체 약국 수
cursor.execute("SELECT COUNT(*) FROM pharmacies")
total_pharmacies = cursor.fetchone()[0]
# 구독 중인 약국 수
cursor.execute('''
SELECT COUNT(DISTINCT pharmacy_id)
FROM pharmacy_subscriptions
WHERE subscription_status = 'ACTIVE'
''')
subscribed_pharmacies = cursor.fetchone()[0]
conn.close()
return jsonify({
'success': True,
'data': {
'services': services,
'total_revenue': total_revenue,
'total_subscriptions': total_subscriptions,
'total_pharmacies': total_pharmacies,
'subscribed_pharmacies': subscribed_pharmacies,
'subscription_rate': round(subscribed_pharmacies / total_pharmacies * 100, 1) if total_pharmacies > 0 else 0
}
})
except Exception as e:
print(f"❌ 구독 통계 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/pharmacies/subscriptions', methods=['GET'])
def api_pharmacy_subscriptions():
"""약국별 구독 현황 조회"""
try:
import sqlite3
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 약국별 구독 현황 조회
cursor.execute('''
SELECT
p.id,
p.pharmacy_name,
p.manager_name,
p.address,
GROUP_CONCAT(sp.product_code) as subscribed_services,
SUM(ps.monthly_fee) as total_monthly_fee
FROM pharmacies p
LEFT JOIN pharmacy_subscriptions ps ON p.id = ps.pharmacy_id
AND ps.subscription_status = 'ACTIVE'
LEFT JOIN service_products sp ON ps.product_id = sp.id
GROUP BY p.id, p.pharmacy_name, p.manager_name, p.address
ORDER BY total_monthly_fee DESC NULLS LAST, p.pharmacy_name
''')
pharmacies = []
for row in cursor.fetchall():
pharmacy_id, name, manager, address, services, fee = row
# 구독 서비스 리스트 변환
service_list = []
if services:
for service_code in services.split(','):
service_list.append(service_code)
pharmacies.append({
'id': pharmacy_id,
'name': name,
'manager': manager,
'address': address,
'subscribed_services': service_list,
'monthly_fee': fee or 0
})
conn.close()
return jsonify({
'success': True,
'data': pharmacies
})
except Exception as e:
print(f"❌ 약국 구독 현황 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/pharmacy/<int:pharmacy_id>/subscriptions', methods=['GET'])
def api_pharmacy_subscription_detail(pharmacy_id):
"""특정 약국의 구독 상세 현황"""
try:
import sqlite3
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 약국 기본 정보
cursor.execute("SELECT pharmacy_name FROM pharmacies WHERE id = ?", (pharmacy_id,))
pharmacy_result = cursor.fetchone()
if not pharmacy_result:
return jsonify({'success': False, 'error': '약국을 찾을 수 없습니다.'}), 404
# 구독 중인 서비스
cursor.execute('''
SELECT
ps.id,
sp.product_code,
sp.product_name,
ps.monthly_fee,
ps.start_date,
ps.next_billing_date,
ps.subscription_status,
ps.notes
FROM pharmacy_subscriptions ps
JOIN service_products sp ON ps.product_id = sp.id
WHERE ps.pharmacy_id = ? AND ps.subscription_status = 'ACTIVE'
ORDER BY sp.product_name
''', (pharmacy_id,))
active_subscriptions = []
for row in cursor.fetchall():
sub_id, code, name, fee, start, next_bill, status, notes = row
active_subscriptions.append({
'id': sub_id,
'code': code,
'name': name,
'monthly_fee': fee,
'start_date': start,
'next_billing_date': next_bill,
'status': status,
'notes': notes
})
# 구독 가능한 서비스 (미구독 서비스)
cursor.execute('''
SELECT sp.id, sp.product_code, sp.product_name, sp.monthly_price, sp.description
FROM service_products sp
WHERE sp.is_active = 1
AND sp.id NOT IN (
SELECT ps.product_id
FROM pharmacy_subscriptions ps
WHERE ps.pharmacy_id = ? AND ps.subscription_status = 'ACTIVE'
)
ORDER BY sp.product_name
''', (pharmacy_id,))
available_services = []
for row in cursor.fetchall():
prod_id, code, name, price, desc = row
available_services.append({
'id': prod_id,
'code': code,
'name': name,
'monthly_price': price,
'description': desc
})
conn.close()
return jsonify({
'success': True,
'data': {
'pharmacy_name': pharmacy_result[0],
'active_subscriptions': active_subscriptions,
'available_services': available_services
}
})
except Exception as e:
print(f"❌ 약국 구독 상세 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/subscriptions', methods=['POST'])
def api_create_subscription():
"""새 구독 생성"""
try:
import sqlite3
from datetime import date, timedelta
data = request.get_json()
pharmacy_id = data.get('pharmacy_id')
product_id = data.get('product_id')
monthly_fee = data.get('monthly_fee')
notes = data.get('notes', '')
if not pharmacy_id or not product_id:
return jsonify({'success': False, 'error': '필수 파라미터가 누락되었습니다.'}), 400
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 중복 구독 확인
cursor.execute('''
SELECT id FROM pharmacy_subscriptions
WHERE pharmacy_id = ? AND product_id = ? AND subscription_status = 'ACTIVE'
''', (pharmacy_id, product_id))
if cursor.fetchone():
conn.close()
return jsonify({'success': False, 'error': '이미 구독 중인 서비스입니다.'}), 400
# 상품 정보 조회
cursor.execute("SELECT monthly_price FROM service_products WHERE id = ?", (product_id,))
product_result = cursor.fetchone()
if not product_result:
conn.close()
return jsonify({'success': False, 'error': '존재하지 않는 서비스입니다.'}), 404
# 월 구독료 설정 (사용자 지정 또는 기본 가격)
if monthly_fee is None:
monthly_fee = product_result[0]
# 구독 생성
start_date = date.today()
next_billing_date = start_date + timedelta(days=30)
cursor.execute('''
INSERT INTO pharmacy_subscriptions
(pharmacy_id, product_id, subscription_status, start_date, next_billing_date, monthly_fee, notes)
VALUES (?, ?, 'ACTIVE', ?, ?, ?, ?)
''', (pharmacy_id, product_id, start_date.isoformat(), next_billing_date.isoformat(), monthly_fee, notes))
conn.commit()
subscription_id = cursor.lastrowid
conn.close()
return jsonify({
'success': True,
'message': '구독이 성공적으로 생성되었습니다.',
'subscription_id': subscription_id
})
except Exception as e:
print(f"❌ 구독 생성 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/subscriptions/<int:subscription_id>', methods=['DELETE'])
def api_cancel_subscription(subscription_id):
"""구독 해지"""
try:
import sqlite3
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 구독 존재 확인
cursor.execute("SELECT id FROM pharmacy_subscriptions WHERE id = ?", (subscription_id,))
if not cursor.fetchone():
conn.close()
return jsonify({'success': False, 'error': '존재하지 않는 구독입니다.'}), 404
# 구독 상태를 CANCELLED로 변경
cursor.execute('''
UPDATE pharmacy_subscriptions
SET subscription_status = 'CANCELLED', updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (subscription_id,))
conn.commit()
conn.close()
return jsonify({
'success': True,
'message': '구독이 성공적으로 해지되었습니다.'
})
except Exception as e:
print(f"❌ 구독 해지 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# =================== 구독 서비스 관리 페이지 ===================
@app.route('/subscriptions')
def subscriptions_page():
"""구독 서비스 관리 페이지"""
return render_template('subscriptions/list.html')
# 에러 핸들러 # 에러 핸들러
@app.errorhandler(404) @app.errorhandler(404)
def not_found_error(error): def not_found_error(error):

View File

@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
PharmQ SaaS 구독 서비스 데이터베이스 테이블 생성 스크립트
"""
import sqlite3
import os
from datetime import datetime
def create_subscription_tables():
"""구독 서비스 관련 테이블 생성"""
# FARMQ 데이터베이스 연결
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("🚀 PharmQ SaaS 구독 서비스 테이블 생성 중...")
try:
# 1. service_products - 서비스 상품 마스터
print("📦 service_products 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS service_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code VARCHAR(20) UNIQUE NOT NULL,
product_name VARCHAR(100) NOT NULL,
description TEXT,
monthly_price DECIMAL(10,2) NOT NULL,
setup_fee DECIMAL(10,2) DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 2. pharmacy_subscriptions - 약국별 구독 현황
print("🏥 pharmacy_subscriptions 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS pharmacy_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
subscription_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
start_date DATE NOT NULL,
end_date DATE,
next_billing_date DATE,
monthly_fee DECIMAL(10,2) NOT NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
FOREIGN KEY (product_id) REFERENCES service_products(id),
UNIQUE(pharmacy_id, product_id)
)
''')
# 3. subscription_usage_logs - 서비스 이용 로그
print("📊 subscription_usage_logs 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS subscription_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL,
usage_type VARCHAR(50) NOT NULL,
usage_amount INTEGER DEFAULT 1,
usage_date DATE NOT NULL,
metadata TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
)
''')
# 4. billing_history - 결제 이력
print("💳 billing_history 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS billing_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL,
billing_period_start DATE NOT NULL,
billing_period_end DATE NOT NULL,
amount DECIMAL(10,2) NOT NULL,
billing_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
billing_date DATE,
payment_method VARCHAR(50),
invoice_number VARCHAR(100),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
)
''')
# 인덱스 생성
print("🔍 인덱스 생성 중...")
indexes = [
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_pharmacy_id ON pharmacy_subscriptions(pharmacy_id)",
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_product_id ON pharmacy_subscriptions(product_id)",
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_status ON pharmacy_subscriptions(subscription_status)",
"CREATE INDEX IF NOT EXISTS idx_usage_logs_subscription_id ON subscription_usage_logs(subscription_id)",
"CREATE INDEX IF NOT EXISTS idx_usage_logs_date ON subscription_usage_logs(usage_date)",
"CREATE INDEX IF NOT EXISTS idx_billing_subscription_id ON billing_history(subscription_id)",
"CREATE INDEX IF NOT EXISTS idx_billing_status ON billing_history(billing_status)"
]
for index_sql in indexes:
cursor.execute(index_sql)
conn.commit()
print("✅ 모든 테이블이 성공적으로 생성되었습니다!")
except Exception as e:
print(f"❌ 오류 발생: {e}")
conn.rollback()
raise
finally:
conn.close()
def insert_sample_service_products():
"""기본 서비스 상품 데이터 삽입"""
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("📦 기본 서비스 상품 데이터 삽입 중...")
# 기본 서비스 상품 정의
products = [
{
'product_code': 'CLOUD_PC',
'product_name': '클라우드 PC',
'description': 'Proxmox 기반 가상 데스크톱 서비스. 언제 어디서나 안전한 클라우드 환경에서 업무 처리 가능.',
'monthly_price': 60000.00,
'setup_fee': 0.00
},
{
'product_code': 'AI_CCTV',
'product_name': 'AI CCTV',
'description': '인공지능 기반 보안 모니터링 시스템. 실시간 이상 상황 탐지 및 알림 서비스.',
'monthly_price': 80000.00,
'setup_fee': 50000.00
},
{
'product_code': 'CRM',
'product_name': 'CRM 시스템',
'description': '고객 관계 관리 및 매출 분석 도구. 고객 데이터 통합 관리 및 마케팅 자동화.',
'monthly_price': 40000.00,
'setup_fee': 0.00
}
]
try:
for product in products:
# 중복 확인
cursor.execute("SELECT id FROM service_products WHERE product_code = ?", (product['product_code'],))
if cursor.fetchone():
print(f"⚠️ {product['product_name']} 상품이 이미 존재합니다.")
continue
cursor.execute('''
INSERT INTO service_products
(product_code, product_name, description, monthly_price, setup_fee)
VALUES (?, ?, ?, ?, ?)
''', (
product['product_code'],
product['product_name'],
product['description'],
product['monthly_price'],
product['setup_fee']
))
print(f"{product['product_name']} 상품 추가됨 (월 {product['monthly_price']:,.0f}원)")
conn.commit()
print("✅ 기본 서비스 상품 데이터 삽입 완료!")
except Exception as e:
print(f"❌ 서비스 상품 데이터 삽입 오류: {e}")
conn.rollback()
raise
finally:
conn.close()
def create_sample_subscriptions():
"""샘플 구독 데이터 생성 (기존 약국 데이터 기반)"""
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("🏥 샘플 구독 데이터 생성 중...")
try:
# 기존 약국 ID 조회
cursor.execute("SELECT id, pharmacy_name FROM pharmacies LIMIT 10")
pharmacies = cursor.fetchall()
# 서비스 상품 ID 조회
cursor.execute("SELECT id, product_code, monthly_price FROM service_products")
products = cursor.fetchall()
if not pharmacies:
print("⚠️ 약국 데이터가 없습니다. 구독 데이터를 생성할 수 없습니다.")
return
if not products:
print("⚠️ 서비스 상품 데이터가 없습니다.")
return
import random
from datetime import date, timedelta
subscription_count = 0
for pharmacy_id, pharmacy_name in pharmacies:
# 각 약국마다 랜덤하게 1-3개의 서비스 구독
num_subscriptions = random.randint(1, 3)
selected_products = random.sample(products, num_subscriptions)
for product_id, product_code, monthly_price in selected_products:
# 중복 구독 확인
cursor.execute('''
SELECT id FROM pharmacy_subscriptions
WHERE pharmacy_id = ? AND product_id = ?
''', (pharmacy_id, product_id))
if cursor.fetchone():
continue # 이미 구독 중
# 구독 시작일 (최근 1년 내 랜덤)
start_date = date.today() - timedelta(days=random.randint(0, 365))
# 다음 결제일 (시작일로부터 월 단위)
next_billing = start_date + timedelta(days=30)
if next_billing < date.today():
# 과거 날짜면 현재 날짜 기준으로 조정
days_since_start = (date.today() - start_date).days
months_passed = days_since_start // 30
next_billing = start_date + timedelta(days=(months_passed + 1) * 30)
cursor.execute('''
INSERT INTO pharmacy_subscriptions
(pharmacy_id, product_id, subscription_status, start_date,
next_billing_date, monthly_fee, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
pharmacy_id,
product_id,
'ACTIVE',
start_date.isoformat(),
next_billing.isoformat(),
monthly_price,
f'{pharmacy_name}{product_code} 서비스 구독'
))
subscription_count += 1
print(f"{pharmacy_name}{product_code} 구독 추가 (월 {monthly_price:,.0f}원)")
conn.commit()
print(f"✅ 총 {subscription_count}개의 샘플 구독 데이터가 생성되었습니다!")
except Exception as e:
print(f"❌ 샘플 구독 데이터 생성 오류: {e}")
conn.rollback()
raise
finally:
conn.close()
def show_subscription_summary():
"""구독 현황 요약 출력"""
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("\n" + "="*60)
print("📊 PharmQ SaaS 구독 현황 요약")
print("="*60)
try:
# 전체 구독 통계
cursor.execute('''
SELECT
sp.product_name,
COUNT(*) as subscription_count,
SUM(ps.monthly_fee) as total_monthly_revenue
FROM pharmacy_subscriptions ps
JOIN service_products sp ON ps.product_id = sp.id
WHERE ps.subscription_status = 'ACTIVE'
GROUP BY sp.product_name
ORDER BY total_monthly_revenue DESC
''')
results = cursor.fetchall()
total_revenue = 0
for product_name, count, revenue in results:
print(f"🔹 {product_name}: {count}개 약국 구독 (월 {revenue:,.0f}원)")
total_revenue += revenue
print(f"\n💰 총 월 매출: {total_revenue:,.0f}")
# 약국별 구독 수
cursor.execute('''
SELECT COUNT(DISTINCT pharmacy_id) as subscribed_pharmacies
FROM pharmacy_subscriptions
WHERE subscription_status = 'ACTIVE'
''')
subscribed_pharmacies = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM pharmacies")
total_pharmacies = cursor.fetchone()[0]
print(f"🏥 구독 약국: {subscribed_pharmacies}/{total_pharmacies}개 ({subscribed_pharmacies/total_pharmacies*100:.1f}%)")
except Exception as e:
print(f"❌ 요약 정보 조회 오류: {e}")
finally:
conn.close()
print("="*60)
if __name__ == "__main__":
print("🚀 PharmQ SaaS 구독 서비스 데이터베이스 초기화")
print("-" * 60)
try:
# 1. 테이블 생성
create_subscription_tables()
print()
# 2. 기본 서비스 상품 삽입
insert_sample_service_products()
print()
# 3. 샘플 구독 데이터 생성
create_sample_subscriptions()
print()
# 4. 구독 현황 요약
show_subscription_summary()
print("\n🎉 PharmQ SaaS 구독 서비스 데이터베이스 초기화 완료!")
except Exception as e:
print(f"\n💥 초기화 실패: {e}")
exit(1)

View File

@ -70,6 +70,37 @@
</div> </div>
</div> </div>
<!-- 구독 서비스 현황 -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-box text-success"></i> 구독 서비스 현황
</h5>
<a href="/subscriptions" class="btn btn-outline-success btn-sm">
<i class="fas fa-cog"></i> 관리하기
</a>
</div>
<div class="card-body">
<div class="row" id="subscription-stats">
<!-- 구독 통계가 여기에 로드됩니다 -->
<div class="col-12 text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<!-- 서비스별 구독 현황 -->
<div class="row mt-4" id="service-breakdown">
<!-- 서비스별 상세 정보가 여기에 로드됩니다 -->
</div>
</div>
</div>
</div>
</div>
<div class="row"> <div class="row">
<!-- 실시간 알림 --> <!-- 실시간 알림 -->
<div class="col-lg-6 mb-4"> <div class="col-lg-6 mb-4">
@ -291,9 +322,132 @@ function updateAlerts() {
.catch(error => console.error('Alert update failed:', error)); .catch(error => console.error('Alert update failed:', error));
} }
// 구독 현황 로드
function loadSubscriptionStats() {
fetch('/api/subscriptions/stats')
.then(response => response.json())
.then(result => {
if (result.success) {
updateSubscriptionDisplay(result.data);
} else {
console.error('구독 통계 로드 실패:', result.error);
}
})
.catch(error => {
console.error('구독 통계 API 오류:', error);
showSubscriptionError();
});
}
function updateSubscriptionDisplay(data) {
// 전체 구독 통계 카드 생성
const statsHtml = `
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #38b2ac 0%, #2c7a7b 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">₩${data.total_revenue.toLocaleString()}</div>
<div class="stat-label">
<i class="fas fa-won-sign"></i> 월 총 매출
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #805ad5 0%, #6b46c1 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">${data.total_subscriptions}</div>
<div class="stat-label">
<i class="fas fa-clipboard-list"></i> 총 구독 수
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">${data.subscribed_pharmacies}/${data.total_pharmacies}</div>
<div class="stat-label">
<i class="fas fa-store"></i> 구독 약국
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number">${data.subscription_rate}%</div>
<div class="stat-label">
<i class="fas fa-percentage"></i> 구독률
</div>
</div>
</div>
</div>
`;
document.getElementById('subscription-stats').innerHTML = statsHtml;
// 서비스별 구독 현황
const serviceIconMap = {
'CLOUD_PC': '💻',
'AI_CCTV': '📷',
'CRM': '📊'
};
const serviceColorMap = {
'CLOUD_PC': '#4f46e5',
'AI_CCTV': '#0891b2',
'CRM': '#ca8a04'
};
let servicesHtml = '';
data.services.forEach(service => {
const icon = serviceIconMap[service.code] || '📦';
const color = serviceColorMap[service.code] || '#6b7280';
const percentage = data.total_pharmacies > 0 ? Math.round(service.count / data.total_pharmacies * 100) : 0;
servicesHtml += `
<div class="col-lg-4 col-md-6 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="me-3" style="font-size: 2rem;">${icon}</div>
<div class="flex-grow-1">
<h6 class="mb-1">${service.name}</h6>
<div class="progress mb-1" style="height: 8px;">
<div class="progress-bar" style="background-color: ${color}; width: ${percentage}%"></div>
</div>
<small class="text-muted">${service.count}개 약국 구독 (${percentage}%)</small>
</div>
</div>
<div class="text-end">
<strong style="color: ${color};">₩${service.revenue.toLocaleString()}/월</strong>
</div>
</div>
</div>
</div>
`;
});
document.getElementById('service-breakdown').innerHTML = servicesHtml;
}
function showSubscriptionError() {
document.getElementById('subscription-stats').innerHTML = `
<div class="col-12 text-center text-muted py-4">
<i class="fas fa-exclamation-triangle fa-3x mb-3"></i>
<p>구독 현황을 불러오는 중 오류가 발생했습니다.</p>
</div>
`;
}
// 페이지 로드 시 구독 현황 로드
loadSubscriptionStats();
// 통계 업데이트 (10초마다 - 더 자주) // 통계 업데이트 (10초마다 - 더 자주)
setInterval(updateStats, 10000); setInterval(updateStats, 10000);
// 알림 업데이트 (30초마다) // 알림 업데이트 (30초마다)
setInterval(updateAlerts, 30000); setInterval(updateAlerts, 30000);
// 구독 현황 업데이트 (60초마다)
setInterval(loadSubscriptionStats, 60000);
</script> </script>
{% endblock %} {% endblock %}

View File

@ -162,6 +162,32 @@
</div> </div>
</div> </div>
<!-- 구독 서비스 정보 -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-box text-success"></i> 구독 서비스 현황
</h5>
<button class="btn btn-outline-success btn-sm" onclick="showSubscriptionModal()">
<i class="fas fa-plus"></i> 새 서비스 구독
</button>
</div>
<div class="card-body">
<div id="subscription-content">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">로딩 중...</span>
</div>
<p class="mt-2 text-muted">구독 정보를 불러오는 중...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 연결된 머신 목록 --> <!-- 연결된 머신 목록 -->
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
@ -315,5 +341,233 @@ function deleteMachine(machineId, machineName) {
showToast('머신 삭제 중 오류가 발생했습니다.', 'error'); showToast('머신 삭제 중 오류가 발생했습니다.', 'error');
}); });
} }
// 구독 정보 로드
function loadSubscriptionInfo() {
const pharmacyId = {{ pharmacy.id }};
fetch(`/api/pharmacy/${pharmacyId}/subscriptions`)
.then(response => response.json())
.then(result => {
if (result.success) {
displaySubscriptionInfo(result.data);
} else {
showSubscriptionError();
}
})
.catch(error => {
console.error('구독 정보 로드 오류:', error);
showSubscriptionError();
});
}
function displaySubscriptionInfo(data) {
const activeSubscriptions = data.active_subscriptions;
const availableServices = data.available_services;
let html = '';
// 활성 구독 서비스
if (activeSubscriptions.length > 0) {
html += `
<div class="row">
<div class="col-12">
<h6 class="mb-3"><i class="fas fa-check-circle text-success"></i> 구독 중인 서비스</h6>
</div>
</div>
<div class="row">
`;
let totalFee = 0;
activeSubscriptions.forEach(sub => {
totalFee += sub.monthly_fee;
const serviceIcons = {
'CLOUD_PC': { icon: '💻', color: 'primary' },
'AI_CCTV': { icon: '📷', color: 'info' },
'CRM': { icon: '📊', color: 'warning' }
};
const service = serviceIcons[sub.code] || { icon: '📦', color: 'secondary' };
html += `
<div class="col-lg-4 col-md-6 mb-3">
<div class="card border-${service.color}">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="me-3" style="font-size: 2rem;">${service.icon}</div>
<div class="flex-grow-1">
<h6 class="mb-1">${sub.name}</h6>
<div class="small text-muted">
시작일: ${sub.start_date}<br>
다음결제: ${sub.next_billing_date}
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div>
<strong class="text-${service.color}">₩${sub.monthly_fee.toLocaleString()}/월</strong>
</div>
<div>
<button class="btn btn-outline-danger btn-sm" onclick="cancelSubscription(${sub.id}, '${sub.name}')">
<i class="fas fa-times"></i> 해지
</button>
</div>
</div>
</div>
</div>
</div>
`;
});
html += `
</div>
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info">
<strong><i class="fas fa-calculator"></i> 총 월 구독료: ₩${totalFee.toLocaleString()}</strong>
</div>
</div>
</div>
`;
}
// 구독 가능한 서비스
if (availableServices.length > 0) {
html += `
<div class="row">
<div class="col-12">
<h6 class="mb-3"><i class="fas fa-plus-circle text-primary"></i> 구독 가능한 서비스</h6>
</div>
</div>
<div class="row">
`;
availableServices.forEach(service => {
const serviceIcons = {
'CLOUD_PC': { icon: '💻', color: 'primary' },
'AI_CCTV': { icon: '📷', color: 'info' },
'CRM': { icon: '📊', color: 'warning' }
};
const serviceInfo = serviceIcons[service.code] || { icon: '📦', color: 'secondary' };
html += `
<div class="col-lg-4 col-md-6 mb-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="me-3" style="font-size: 2rem;">${serviceInfo.icon}</div>
<div class="flex-grow-1">
<h6 class="mb-1">${service.name}</h6>
<p class="small text-muted mb-2">${service.description}</p>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div>
<strong class="text-${serviceInfo.color}">₩${service.monthly_price.toLocaleString()}/월</strong>
</div>
<div>
<button class="btn btn-${serviceInfo.color} btn-sm" onclick="subscribeService(${service.id}, '${service.name}', ${service.monthly_price})">
<i class="fas fa-plus"></i> 구독
</button>
</div>
</div>
</div>
</div>
</div>
`;
});
html += '</div>';
}
if (activeSubscriptions.length === 0 && availableServices.length === 0) {
html = `
<div class="text-center py-4">
<i class="fas fa-box-open fa-3x text-muted mb-3"></i>
<p class="text-muted">구독 가능한 서비스가 없습니다.</p>
</div>
`;
}
document.getElementById('subscription-content').innerHTML = html;
}
function showSubscriptionError() {
document.getElementById('subscription-content').innerHTML = `
<div class="text-center py-4">
<i class="fas fa-exclamation-triangle fa-3x text-warning mb-3"></i>
<p class="text-muted">구독 정보를 불러오는 중 오류가 발생했습니다.</p>
<button class="btn btn-outline-primary btn-sm" onclick="loadSubscriptionInfo()">
<i class="fas fa-redo"></i> 다시 시도
</button>
</div>
`;
}
function subscribeService(serviceId, serviceName, monthlyPrice) {
if (!confirm(`"${serviceName}" 서비스를 월 ₩${monthlyPrice.toLocaleString()}에 구독하시겠습니까?`)) {
return;
}
const pharmacyId = {{ pharmacy.id }};
fetch('/api/subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pharmacy_id: pharmacyId,
product_id: serviceId,
monthly_fee: monthlyPrice
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
showToast(result.message, 'success');
setTimeout(() => loadSubscriptionInfo(), 1000);
} else {
showToast(result.error, 'error');
}
})
.catch(error => {
console.error('구독 생성 오류:', error);
showToast('구독 생성 중 오류가 발생했습니다.', 'error');
});
}
function cancelSubscription(subscriptionId, serviceName) {
if (!confirm(`"${serviceName}" 서비스 구독을 해지하시겠습니까?`)) {
return;
}
fetch(`/api/subscriptions/${subscriptionId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(result => {
if (result.success) {
showToast(result.message, 'success');
setTimeout(() => loadSubscriptionInfo(), 1000);
} else {
showToast(result.error, 'error');
}
})
.catch(error => {
console.error('구독 해지 오류:', error);
showToast('구독 해지 중 오류가 발생했습니다.', 'error');
});
}
// 페이지 로드 시 구독 정보 로드
document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => loadSubscriptionInfo(), 500);
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -40,6 +40,7 @@
<tr> <tr>
<th>약국 정보</th> <th>약국 정보</th>
<th>담당자</th> <th>담당자</th>
<th>구독 서비스</th>
<th>연결된 머신</th> <th>연결된 머신</th>
<th>네트워크 상태</th> <th>네트워크 상태</th>
<th>액션</th> <th>액션</th>
@ -68,6 +69,15 @@
<i class="fas fa-phone"></i> {{ pharmacy_data.phone or '연락처 미등록' }} <i class="fas fa-phone"></i> {{ pharmacy_data.phone or '연락처 미등록' }}
</div> </div>
</td> </td>
<td>
<div class="subscription-services" data-pharmacy-id="{{ pharmacy_data.id }}">
<div class="d-flex justify-content-center">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">로딩...</span>
</div>
</div>
</div>
</td>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="badge bg-info me-2">{{ pharmacy_data.machine_count }}대</span> <span class="badge bg-info me-2">{{ pharmacy_data.machine_count }}대</span>
@ -200,6 +210,9 @@ let pharmacyModal;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
pharmacyModal = new bootstrap.Modal(document.getElementById('pharmacyModal')); pharmacyModal = new bootstrap.Modal(document.getElementById('pharmacyModal'));
// 구독 상태 로드
setTimeout(loadSubscriptionStatuses, 500); // 페이지 로드 후 약간의 지연
}); });
function showAddModal() { function showAddModal() {
@ -316,6 +329,94 @@ document.getElementById('pharmacyForm').addEventListener('submit', function(e) {
} }
}); });
// 구독 서비스 상태 로드
function loadSubscriptionStatuses() {
const subscriptionContainers = document.querySelectorAll('.subscription-services');
subscriptionContainers.forEach(container => {
const pharmacyId = container.dataset.pharmacyId;
fetch(`/api/pharmacy/${pharmacyId}/subscriptions`)
.then(response => response.json())
.then(result => {
if (result.success) {
displaySubscriptionStatus(container, result.data);
} else {
showSubscriptionError(container);
}
})
.catch(error => {
console.error(`약국 ${pharmacyId} 구독 정보 로드 실패:`, error);
showSubscriptionError(container);
});
});
}
function displaySubscriptionStatus(container, data) {
const subscriptions = data.active_subscriptions;
if (subscriptions.length === 0) {
container.innerHTML = `
<div class="text-center">
<span class="badge bg-light text-muted">
<i class="fas fa-minus"></i> 구독 없음
</span>
</div>
`;
return;
}
// 서비스 아이콘 맵핑
const serviceIcons = {
'CLOUD_PC': { icon: '💻', color: 'primary', name: '클라우드PC' },
'AI_CCTV': { icon: '📷', color: 'info', name: 'AI CCTV' },
'CRM': { icon: '📊', color: 'warning', name: 'CRM' }
};
let html = '<div class="d-flex flex-wrap gap-1">';
let totalFee = 0;
subscriptions.forEach(sub => {
const service = serviceIcons[sub.code] || { icon: '📦', color: 'secondary', name: sub.name };
totalFee += sub.monthly_fee;
html += `
<span class="badge bg-${service.color}" title="${service.name} - ₩${sub.monthly_fee.toLocaleString()}/월" data-bs-toggle="tooltip">
${service.icon}
</span>
`;
});
html += '</div>';
// 총 월 구독료 표시
html += `
<div class="small text-muted text-center mt-1">
<strong>₩${totalFee.toLocaleString()}/월</strong>
</div>
`;
container.innerHTML = html;
// 툴팁 초기화
const tooltipTriggerList = container.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(tooltipTriggerEl => {
new bootstrap.Tooltip(tooltipTriggerEl);
});
}
function showSubscriptionError(container) {
container.innerHTML = `
<div class="text-center">
<span class="badge bg-danger">
<i class="fas fa-exclamation-triangle"></i> 오류
</span>
</div>
`;
}
// 구독 상태 로드 함수들은 위의 DOMContentLoaded에서 호출됨
// 테이블 정렬 및 검색 기능 추가 (향후) // 테이블 정렬 및 검색 기능 추가 (향후)
</script> </script>
{% endblock %} {% endblock %}

156
fix-database-constraints.py Executable file
View File

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
Headscale 데이터베이스 외래키 제약조건 수정 스크립트
"""
import sqlite3
import os
from datetime import datetime
def fix_database_constraints():
"""외래키 제약조건 문제 해결"""
db_path = '/srv/headscale-setup/data/db.sqlite'
backup_path = f'/srv/headscale-setup/data/db.sqlite.backup.{datetime.now().strftime("%Y%m%d_%H%M%S")}'
print("🔧 Headscale 데이터베이스 외래키 제약조건 수정")
print("=" * 60)
# 1. 백업 생성
print(f"📦 데이터베이스 백업 중: {backup_path}")
import shutil
shutil.copy2(db_path, backup_path)
print("✅ 백업 완료")
# 2. 데이터베이스 연결
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# 3. 외래키 제약조건 비활성화
print("🔓 외래키 제약조건 비활성화 중...")
cursor.execute("PRAGMA foreign_keys = OFF")
# 4. 기존 테이블들 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%pharmacy%'")
pharmacy_tables = cursor.fetchall()
print(f"📋 발견된 약국 관련 테이블: {[table[0] for table in pharmacy_tables]}")
# 5. pharmacy_info 테이블 재생성 (외래키 없이)
if any('pharmacy_info' in table[0] for table in pharmacy_tables):
print("🔄 pharmacy_info 테이블 재생성 중...")
# 기존 데이터 백업
cursor.execute("SELECT * FROM pharmacy_info")
existing_data = cursor.fetchall()
# 기존 테이블 구조 확인
cursor.execute("PRAGMA table_info(pharmacy_info)")
columns = cursor.fetchall()
print(f"📊 기존 컬럼: {[col[1] for col in columns]}")
# 새 테이블 생성 (외래키 제약조건 없음)
cursor.execute("DROP TABLE IF EXISTS pharmacy_info_new")
cursor.execute("""
CREATE TABLE pharmacy_info_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_name VARCHAR(255) NOT NULL,
business_number VARCHAR(20),
manager_name VARCHAR(100),
phone VARCHAR(20),
address TEXT,
proxmox_host VARCHAR(255),
user_id VARCHAR(255), -- 외래키 제약조건 제거
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 기존 데이터 복사
if existing_data:
print(f"📁 기존 데이터 복사 중... ({len(existing_data)}개 레코드)")
cursor.executemany("""
INSERT INTO pharmacy_info_new
(id, pharmacy_name, business_number, manager_name, phone, address, proxmox_host, user_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", existing_data)
# 기존 테이블 삭제 및 새 테이블로 교체
cursor.execute("DROP TABLE pharmacy_info")
cursor.execute("ALTER TABLE pharmacy_info_new RENAME TO pharmacy_info")
print("✅ pharmacy_info 테이블 재생성 완료")
# 6. machine_specs 테이블도 수정 (필요시)
if any('machine_specs' in table[0] for table in pharmacy_tables):
print("🔄 machine_specs 테이블 확인 중...")
cursor.execute("PRAGMA table_info(machine_specs)")
columns = cursor.fetchall()
# 외래키 제약조건이 있는지 확인
cursor.execute("SELECT sql FROM sqlite_master WHERE name='machine_specs'")
table_sql = cursor.fetchone()
if table_sql and 'REFERENCES' in table_sql[0]:
print("🔄 machine_specs 테이블 재생성 중...")
# 기존 데이터 백업
cursor.execute("SELECT * FROM machine_specs")
existing_specs = cursor.fetchall()
# 새 테이블 생성 (외래키 제약조건 없음)
cursor.execute("DROP TABLE IF EXISTS machine_specs_new")
cursor.execute("""
CREATE TABLE machine_specs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id INTEGER, -- 외래키 제약조건 제거
pharmacy_id INTEGER, -- 외래키 제약조건 제거
cpu_model VARCHAR(255),
cpu_cores INTEGER,
ram_gb INTEGER,
storage_gb INTEGER,
network_speed INTEGER,
os_info VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 기존 데이터 복사
if existing_specs:
print(f"📁 기존 스펙 데이터 복사 중... ({len(existing_specs)}개 레코드)")
cursor.executemany("""
INSERT INTO machine_specs_new
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", existing_specs)
# 기존 테이블 삭제 및 새 테이블로 교체
cursor.execute("DROP TABLE machine_specs")
cursor.execute("ALTER TABLE machine_specs_new RENAME TO machine_specs")
print("✅ machine_specs 테이블 재생성 완료")
# 7. 변경사항 커밋
conn.commit()
print("💾 변경사항 저장 완료")
# 8. 외래키 제약조건 재활성화 (필요시)
cursor.execute("PRAGMA foreign_keys = ON")
# 9. 무결성 검사
print("🔍 데이터베이스 무결성 검사 중...")
cursor.execute("PRAGMA integrity_check")
integrity_result = cursor.fetchone()
print(f"✅ 무결성 검사 결과: {integrity_result[0]}")
print("\n🎉 데이터베이스 수정 완료!")
print(f"📦 백업 위치: {backup_path}")
print("🚀 이제 Tailscale 클라이언트 등록을 다시 시도해보세요!")
except Exception as e:
print(f"❌ 오류 발생: {e}")
conn.rollback()
print(f"🔙 백업에서 복원하려면: cp {backup_path} {db_path}")
raise
finally:
conn.close()
if __name__ == '__main__':
fix_database_constraints()

56
quick-fix-db.py Normal file
View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
빠른 데이터베이스 수정 - 외래키 제약조건만 제거
"""
import sqlite3
import os
from datetime import datetime
def quick_fix():
"""빠르게 외래키 제약조건만 비활성화"""
db_path = '/srv/headscale-setup/data/db.sqlite'
print("🔧 빠른 수정: 외래키 제약조건 비활성화")
print("=" * 50)
# Docker 컨테이너 중지
print("⏹️ Headscale 컨테이너 중지 중...")
os.system("cd /srv/headscale-setup && docker-compose stop headscale")
# 데이터베이스 연결
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# 외래키 제약조건 영구 비활성화
print("🔓 외래키 제약조건 비활성화...")
cursor.execute("PRAGMA foreign_keys = OFF")
# 설정 확인
cursor.execute("PRAGMA foreign_keys")
fk_status = cursor.fetchone()[0]
print(f"✅ 외래키 상태: {'비활성화' if fk_status == 0 else '활성화'}")
# 테이블 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print(f"📋 테이블 목록: {[table[0] for table in tables]}")
conn.commit()
print("💾 설정 저장 완료")
except Exception as e:
print(f"❌ 오류: {e}")
conn.rollback()
finally:
conn.close()
# Docker 컨테이너 재시작
print("🚀 Headscale 컨테이너 재시작 중...")
os.system("cd /srv/headscale-setup && docker-compose start headscale")
print("✅ 완료! 이제 Tailscale 클라이언트 등록을 다시 시도해보세요.")
if __name__ == '__main__':
quick_fix()

222
test-proxmox-api.py Executable file
View File

@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
Proxmox VE API 테스트 스크립트
사용법: python test-proxmox-api.py <proxmox-ip> [root-password]
"""
import requests
import json
import sys
import urllib3
from urllib.parse import quote_plus
# SSL 경고 무시
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def test_api_token_method(host, token):
"""API Token 방식 테스트"""
print("🔑 API Token 방식 테스트...")
headers = {
'Authorization': f'PVEAPIToken={token}'
}
try:
response = requests.get(
f'https://{host}:443/api2/json/version',
headers=headers,
verify=False,
timeout=10
)
if response.status_code == 200:
version_info = response.json()['data']
print(f"✅ API Token 인증 성공!")
print(f" Proxmox Version: {version_info['version']}")
print(f" Release: {version_info['release']}")
return True
else:
print(f"❌ API Token 인증 실패: {response.status_code}")
print(f" 응답: {response.text}")
return False
except Exception as e:
print(f"❌ API Token 연결 실패: {e}")
return False
def test_session_method(host, username, password):
"""세션 쿠키 방식 테스트"""
print("🍪 세션 쿠키 방식 테스트...")
# 1단계: 로그인하여 티켓 획득
login_data = {
'username': username,
'password': password
}
try:
response = requests.post(
f'https://{host}:443/api2/json/access/ticket',
data=login_data,
verify=False,
timeout=10
)
if response.status_code != 200:
print(f"❌ 로그인 실패: {response.status_code}")
print(f" 응답: {response.text}")
return False, None, None
data = response.json()['data']
ticket = data['ticket']
csrf_token = data['CSRFPreventionToken']
print("✅ 로그인 성공!")
print(f" 티켓: {ticket[:20]}...")
print(f" CSRF: {csrf_token}")
return True, ticket, csrf_token
except Exception as e:
print(f"❌ 로그인 연결 실패: {e}")
return False, None, None
def test_vm_list_api(host, ticket=None, csrf_token=None, api_token=None):
"""VM 목록 조회 API 테스트"""
print("\n📋 VM 목록 조회 API 테스트...")
if api_token:
headers = {'Authorization': f'PVEAPIToken={api_token}'}
cookies = None
else:
headers = {'CSRFPreventionToken': csrf_token}
cookies = {'PVEAuthCookie': ticket}
try:
response = requests.get(
f'https://{host}:443/api2/json/cluster/resources?type=vm',
headers=headers,
cookies=cookies,
verify=False,
timeout=10
)
if response.status_code == 200:
vms = response.json()['data']
print(f"✅ VM 목록 조회 성공! (총 {len(vms)}개)")
for vm in vms:
status_icon = "🟢" if vm.get('status') == 'running' else "🔴"
print(f" {status_icon} VM {vm.get('vmid')}: {vm.get('name', 'N/A')} ({vm.get('status', 'unknown')})")
return True, vms
else:
print(f"❌ VM 목록 조회 실패: {response.status_code}")
print(f" 응답: {response.text}")
return False, []
except Exception as e:
print(f"❌ VM 목록 API 연결 실패: {e}")
return False, []
def test_vnc_proxy_api(host, node, vmid, ticket=None, csrf_token=None, api_token=None):
"""VNC 프록시 티켓 생성 테스트"""
print(f"\n🖥️ VNC 프록시 API 테스트 (VM {vmid})...")
if api_token:
headers = {'Authorization': f'PVEAPIToken={api_token}'}
cookies = None
else:
headers = {'CSRFPreventionToken': csrf_token}
cookies = {'PVEAuthCookie': ticket}
data = {'websocket': '1'}
try:
response = requests.post(
f'https://{host}:443/api2/json/nodes/{node}/qemu/{vmid}/vncproxy',
headers=headers,
cookies=cookies,
data=data,
verify=False,
timeout=10
)
if response.status_code == 200:
vnc_data = response.json()['data']
print("✅ VNC 프록시 티켓 생성 성공!")
print(f" 포트: {vnc_data['port']}")
print(f" 티켓: {vnc_data['ticket'][:20]}...")
# WebSocket URL 생성
encoded_ticket = quote_plus(vnc_data['ticket'])
ws_url = f"wss://{host}:443/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}"
print(f" WebSocket URL: {ws_url[:80]}...")
return True, vnc_data
else:
print(f"❌ VNC 프록시 생성 실패: {response.status_code}")
print(f" 응답: {response.text}")
return False, None
except Exception as e:
print(f"❌ VNC 프록시 API 연결 실패: {e}")
return False, None
def main():
if len(sys.argv) < 2:
print("사용법: python test-proxmox-api.py <proxmox-ip> [root-password] [api-token]")
print()
print("예시:")
print(" python test-proxmox-api.py 100.64.0.1 mypassword")
print(" python test-proxmox-api.py 100.64.0.1 '' root@pam!token=uuid")
sys.exit(1)
host = sys.argv[1]
password = sys.argv[2] if len(sys.argv) > 2 else None
api_token = sys.argv[3] if len(sys.argv) > 3 else None
print("=" * 60)
print(f"🚀 Proxmox API 테스트 - {host}")
print("=" * 60)
# API Token 방식 테스트
if api_token:
success = test_api_token_method(host, api_token)
if success:
# VM 목록 테스트
vm_success, vms = test_vm_list_api(host, api_token=api_token)
# 실행 중인 VM이 있으면 VNC 테스트
if vm_success and vms:
running_vm = next((vm for vm in vms if vm.get('status') == 'running'), None)
if running_vm:
test_vnc_proxy_api(host, running_vm['node'], running_vm['vmid'], api_token=api_token)
else:
print("⚠️ 실행 중인 VM이 없어 VNC 테스트를 건너뜁니다.")
# 패스워드 방식 테스트
elif password:
login_success, ticket, csrf_token = test_session_method(host, 'root@pam', password)
if login_success:
# VM 목록 테스트
vm_success, vms = test_vm_list_api(host, ticket, csrf_token)
# 실행 중인 VM이 있으면 VNC 테스트
if vm_success and vms:
running_vm = next((vm for vm in vms if vm.get('status') == 'running'), None)
if running_vm:
test_vnc_proxy_api(host, running_vm['node'], running_vm['vmid'], ticket, csrf_token)
else:
print("⚠️ 실행 중인 VM이 없어 VNC 테스트를 건너뜁니다.")
else:
print("❌ 패스워드 또는 API 토큰을 제공해주세요.")
sys.exit(1)
print("\n" + "=" * 60)
print("✅ 테스트 완료!")
if __name__ == '__main__':
main()