feat: 애니팜 투약지도서 API 및 마스터 데이터 업데이트
- 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>
This commit is contained in:
379
anipharm_api.py
Normal file
379
anipharm_api.py
Normal file
@@ -0,0 +1,379 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user