- anipharm_api.py: 동물약 PDF 생성 API 추가 - data/master/*.json: 16종 마스터 데이터 업데이트 - templates: medication_guide_v2, 로고 추가 - docs: AI 매핑 아키텍처, API 스펙 문서 - .gitignore: _dev_scripts/, *.db 제외 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
애니팜 투약지도서 API
|
|
- 동물약 PDF 생성 API
|
|
- 포트: 7002
|
|
"""
|
|
|
|
from flask import Flask, request, jsonify, send_file, render_template_string
|
|
from flask_cors import CORS
|
|
import os
|
|
import tempfile
|
|
import uuid
|
|
import sqlite3
|
|
from datetime import datetime
|
|
|
|
from animal_med.renderer_v2 import AnimalMedRendererV2
|
|
|
|
app = Flask(__name__)
|
|
CORS(app)
|
|
|
|
# 렌더러 인스턴스 (싱글톤)
|
|
renderer = AnimalMedRendererV2()
|
|
|
|
# 출력 디렉토리
|
|
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'output')
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# SQLite 접속 로그 DB
|
|
# ─────────────────────────────────────────────────────────────
|
|
LOGS_DB = os.path.join(os.path.dirname(__file__), 'lecture_logs.db')
|
|
|
|
def init_logs_db():
|
|
"""접속 로그 DB 초기화"""
|
|
conn = sqlite3.connect(LOGS_DB)
|
|
c = conn.cursor()
|
|
c.execute('''
|
|
CREATE TABLE IF NOT EXISTS lecture_views (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
lecture_id INTEGER NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
referer TEXT,
|
|
viewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
c.execute('CREATE INDEX IF NOT EXISTS idx_lecture_id ON lecture_views(lecture_id)')
|
|
c.execute('CREATE INDEX IF NOT EXISTS idx_viewed_at ON lecture_views(viewed_at)')
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def log_lecture_view(lecture_id, ip, user_agent, referer):
|
|
"""강의 조회 로깅"""
|
|
try:
|
|
conn = sqlite3.connect(LOGS_DB)
|
|
c = conn.cursor()
|
|
c.execute('''
|
|
INSERT INTO lecture_views (lecture_id, ip_address, user_agent, referer)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (lecture_id, ip, user_agent[:500] if user_agent else None, referer[:500] if referer else None))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception as e:
|
|
print(f"[LOG ERROR] {e}")
|
|
|
|
# DB 초기화
|
|
init_logs_db()
|
|
|
|
|
|
@app.route('/health', methods=['GET'])
|
|
def health():
|
|
"""헬스체크"""
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'service': 'anipharm-api',
|
|
'timestamp': datetime.now().isoformat()
|
|
})
|
|
|
|
|
|
@app.route('/api/products', methods=['GET'])
|
|
def list_products():
|
|
"""등록된 약품 목록 조회"""
|
|
products = renderer.list_drugs()
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(products),
|
|
'products': products
|
|
})
|
|
|
|
|
|
@app.route('/api/guide/pdf', methods=['POST'])
|
|
def generate_pdf():
|
|
"""
|
|
투약지도서 PDF 생성
|
|
|
|
Request Body:
|
|
{
|
|
"product_ids": ["nexgard_spectra", "heartsaver", ...],
|
|
"patient_name": "김남곤",
|
|
"pet_name": "뽀삐",
|
|
"pet_species": "푸들",
|
|
"pet_age": "3세",
|
|
"pharmacy_name": "청춘약국 동물약 전문상담", // optional
|
|
"pharmacy_tel": "033-481-0384" // optional
|
|
}
|
|
|
|
Response: PDF 파일 (application/pdf)
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'success': False, 'error': 'No JSON data'}), 400
|
|
|
|
product_ids = data.get('product_ids', [])
|
|
if not product_ids:
|
|
return jsonify({'success': False, 'error': 'product_ids required'}), 400
|
|
|
|
# 파일명 생성
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f"guide_{timestamp}_{uuid.uuid4().hex[:8]}.pdf"
|
|
output_path = os.path.join(OUTPUT_DIR, filename)
|
|
|
|
# PDF 생성
|
|
result = renderer.render_to_pdf(
|
|
product_ids=product_ids,
|
|
output_path=output_path,
|
|
patient_name=data.get('patient_name', '보호자'),
|
|
pet_name=data.get('pet_name', '반려동물'),
|
|
pet_species=data.get('pet_species', ''),
|
|
pet_age=data.get('pet_age', ''),
|
|
pharmacy_name=data.get('pharmacy_name', '청춘약국 동물약 전문상담'),
|
|
pharmacy_tel=data.get('pharmacy_tel', '033-481-0384')
|
|
)
|
|
|
|
if result['success']:
|
|
return send_file(
|
|
output_path,
|
|
mimetype='application/pdf',
|
|
as_attachment=True,
|
|
download_name=f"투약지도서_{timestamp}.pdf"
|
|
)
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': result.get('error', 'Unknown error')
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@app.route('/api/guide/preview', methods=['POST'])
|
|
def preview_guide():
|
|
"""
|
|
투약지도서 미리보기 (메타데이터만 반환)
|
|
|
|
Request Body: generate_pdf와 동일
|
|
Response: JSON (약품 정보 + 예상 페이지 수)
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'success': False, 'error': 'No JSON data'}), 400
|
|
|
|
product_ids = data.get('product_ids', [])
|
|
if not product_ids:
|
|
return jsonify({'success': False, 'error': 'product_ids required'}), 400
|
|
|
|
# 약품 정보 조회
|
|
drugs = []
|
|
for pid in product_ids:
|
|
drug = renderer.get_drug(pid)
|
|
if drug:
|
|
drugs.append({
|
|
'id': pid,
|
|
'name': drug.get('name'),
|
|
'category': drug.get('category'),
|
|
'has_image': bool(drug.get('image_url') or drug.get('apc_code'))
|
|
})
|
|
|
|
# 페이지 수 계산 (4개/페이지)
|
|
page_count = (len(drugs) + 3) // 4
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'drug_count': len(drugs),
|
|
'page_count': page_count,
|
|
'drugs': drugs
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# 동물약 강의 콘텐츠 라우트
|
|
# ─────────────────────────────────────────────────────────────
|
|
LECTURES_DIR = os.path.join(os.path.dirname(__file__), 'static', 'lectures')
|
|
os.makedirs(LECTURES_DIR, exist_ok=True)
|
|
|
|
@app.route('/lecture/<int:lecture_id>')
|
|
def serve_lecture(lecture_id):
|
|
"""동물약 강의 콘텐츠 서빙 (카카오톡 og 태그 포함) + 접속 로깅"""
|
|
from flask import send_from_directory
|
|
|
|
# 접속 로깅
|
|
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
|
user_agent = request.headers.get('User-Agent', '')
|
|
referer = request.headers.get('Referer', '')
|
|
log_lecture_view(lecture_id, ip, user_agent, referer)
|
|
|
|
filename = f'lecture_{lecture_id:02d}.html'
|
|
return send_from_directory(LECTURES_DIR, filename)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# 관리자 페이지 - 강의 접속 통계
|
|
# ─────────────────────────────────────────────────────────────
|
|
ADMIN_HTML = '''
|
|
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>📊 강의 접속 통계 - 애니팜</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { font-family: 'Pretendard', -apple-system, sans-serif; background: #f5f7fa; margin: 0; padding: 20px; }
|
|
.container { max-width: 1000px; margin: 0 auto; }
|
|
h1 { color: #008BD5; margin-bottom: 30px; }
|
|
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
|
.card h2 { margin-top: 0; color: #333; font-size: 1.2em; border-bottom: 2px solid #008BD5; padding-bottom: 10px; }
|
|
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
|
.stat-box { background: linear-gradient(135deg, #008BD5, #00a8e8); color: white; padding: 20px; border-radius: 10px; text-align: center; }
|
|
.stat-box .number { font-size: 2.5em; font-weight: bold; }
|
|
.stat-box .label { font-size: 0.9em; opacity: 0.9; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
|
th { background: #f8f9fa; color: #666; font-weight: 600; }
|
|
tr:hover { background: #f8f9fa; }
|
|
.ip { font-family: monospace; color: #666; font-size: 0.9em; }
|
|
.time { color: #888; font-size: 0.85em; }
|
|
.ua { color: #999; font-size: 0.75em; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.badge { display: inline-block; padding: 4px 10px; border-radius: 20px; font-size: 0.8em; font-weight: 600; }
|
|
.badge-lecture { background: #e3f2fd; color: #1976d2; }
|
|
.refresh-btn { background: #008BD5; color: white; border: none; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-size: 1em; }
|
|
.refresh-btn:hover { background: #0077b6; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>📊 강의 접속 통계</h1>
|
|
|
|
<div class="stat-grid">
|
|
<div class="stat-box">
|
|
<div class="number">{{ total_views }}</div>
|
|
<div class="label">총 조회수</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="number">{{ today_views }}</div>
|
|
<div class="label">오늘 조회수</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="number">{{ unique_ips }}</div>
|
|
<div class="label">순 방문자 (IP)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>📈 강의별 조회수</h2>
|
|
<table>
|
|
<thead>
|
|
<tr><th>강의</th><th>조회수</th><th>최근 조회</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in lecture_stats %}
|
|
<tr>
|
|
<td><span class="badge badge-lecture">Lecture {{ row[0] }}</span></td>
|
|
<td><strong>{{ row[1] }}</strong>회</td>
|
|
<td class="time">{{ row[2] }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>🕐 최근 접속 로그 (50건)</h2>
|
|
<table>
|
|
<thead>
|
|
<tr><th>시간</th><th>강의</th><th>IP</th><th>Referer</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in recent_logs %}
|
|
<tr>
|
|
<td class="time">{{ row[0] }}</td>
|
|
<td><span class="badge badge-lecture">Lecture {{ row[1] }}</span></td>
|
|
<td class="ip">{{ row[2] }}</td>
|
|
<td class="ua">{{ row[3] or '-' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<button class="refresh-btn" onclick="location.reload()">🔄 새로고침</button>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
@app.route('/admin/lectures')
|
|
def admin_lectures():
|
|
"""강의 접속 통계 관리자 페이지"""
|
|
conn = sqlite3.connect(LOGS_DB)
|
|
c = conn.cursor()
|
|
|
|
# 총 조회수
|
|
c.execute('SELECT COUNT(*) FROM lecture_views')
|
|
total_views = c.fetchone()[0]
|
|
|
|
# 오늘 조회수
|
|
c.execute("SELECT COUNT(*) FROM lecture_views WHERE date(viewed_at) = date('now', 'localtime')")
|
|
today_views = c.fetchone()[0]
|
|
|
|
# 순 방문자 (unique IP)
|
|
c.execute('SELECT COUNT(DISTINCT ip_address) FROM lecture_views')
|
|
unique_ips = c.fetchone()[0]
|
|
|
|
# 강의별 통계
|
|
c.execute('''
|
|
SELECT lecture_id, COUNT(*) as cnt, MAX(viewed_at) as last_view
|
|
FROM lecture_views
|
|
GROUP BY lecture_id
|
|
ORDER BY cnt DESC
|
|
''')
|
|
lecture_stats = c.fetchall()
|
|
|
|
# 최근 로그 50건
|
|
c.execute('''
|
|
SELECT viewed_at, lecture_id, ip_address, referer
|
|
FROM lecture_views
|
|
ORDER BY viewed_at DESC
|
|
LIMIT 50
|
|
''')
|
|
recent_logs = c.fetchall()
|
|
|
|
conn.close()
|
|
|
|
return render_template_string(ADMIN_HTML,
|
|
total_views=total_views,
|
|
today_views=today_views,
|
|
unique_ips=unique_ips,
|
|
lecture_stats=lecture_stats,
|
|
recent_logs=recent_logs
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print("=" * 50)
|
|
print("🐾 애니팜 투약지도서 API")
|
|
print("=" * 50)
|
|
print(f"📍 http://localhost:7002")
|
|
print(f"📋 GET /health - 헬스체크")
|
|
print(f"📋 GET /api/products - 약품 목록")
|
|
print(f"📋 POST /api/guide/pdf - PDF 생성")
|
|
print(f"📋 POST /api/guide/preview - 미리보기")
|
|
print("=" * 50)
|
|
|
|
app.run(host='0.0.0.0', port=7002, debug=False, threaded=True)
|