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:
parent
c37cf023c1
commit
35ecd4748e
508
FLASK_ADMIN_DEVELOPMENT_PLAN.md
Normal file
508
FLASK_ADMIN_DEVELOPMENT_PLAN.md
Normal 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
424
HEADSCALE_COMPLETE_GUIDE.md
Normal 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 네트워크를 운영할 수 있습니다!**
|
||||||
480
PREAUTH_KEY_MANAGEMENT_GUIDE.md
Normal file
480
PREAUTH_KEY_MANAGEMENT_GUIDE.md
Normal 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 네트워크를 운영하세요!**
|
||||||
376
PROXMOX_VNC_INTEGRATION_PLAN.md
Normal file
376
PROXMOX_VNC_INTEGRATION_PLAN.md
Normal 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
297
PharmQ-SaaS-Service-Plan.md
Normal 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
196
add-client.sh
Executable 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
74
clean-database.py
Normal 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()
|
||||||
@ -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):
|
||||||
|
|||||||
347
farmq-admin/create_subscription_tables.py
Normal file
347
farmq-admin/create_subscription_tables.py
Normal 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)
|
||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
@ -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
156
fix-database-constraints.py
Executable 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
56
quick-fix-db.py
Normal 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
222
test-proxmox-api.py
Executable 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()
|
||||||
Loading…
Reference in New Issue
Block a user