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:
청춘약국
2026-04-06 18:07:12 +09:00
parent 8a18b530bd
commit dab2ecae44
27 changed files with 3880 additions and 391 deletions

379
anipharm_api.py Normal file
View 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)