commit 4ff5dba4b17e1110616137f5f557e47ed2d1d207 Author: KWON Date: Wed Dec 31 09:56:37 2025 +0900 feat: initial commit - unified FGTools from static, weather, mattermost-noti diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..2028e7f --- /dev/null +++ b/.env.sample @@ -0,0 +1,110 @@ +# =================================================================== +# FGTools - First Garden 통합 도구 설정 파일 +# =================================================================== +# 이 파일을 복사하여 .env 파일로 저장하고 실제 값을 입력하세요. +# cp .env.sample .env +# =================================================================== + +# ===== 공통 설정 ===== +# 디버그 모드 (true: 개발 환경, false: 운영 환경) +DEBUG=false +# 로그 레벨 (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO +# 병렬 처리 워커 수 +MAX_WORKERS=4 + +# ===== 메인 데이터베이스 설정 (MySQL/MariaDB) ===== +# 정적 데이터 관리 시스템에서 사용 +DB_HOST=localhost +DB_USER=your_db_user +DB_PASSWORD=your_db_password +DB_NAME=your_db_name +DB_CHARSET=utf8mb4 + +# 테이블 접두사 (예: fg_manager_static_) +TABLE_PREFIX=fg_manager_static_ + +# ===== 공공데이터포털 API 설정 ===== +# https://data.go.kr 에서 발급받은 API 키 +DATA_API_SERVICE_KEY=your_data_api_service_key +DATA_API_START_DATE=20170101 +DATA_API_END_DATE=20250701 + +# 대기질 측정소명 (쉼표로 구분) +AIR_STATION_NAMES=운정 + +# 기상청 관측소 ID (쉼표로 구분) +WEATHER_STN_IDS=99 + +# ===== Google Analytics 4 설정 ===== +# GA4 API를 통한 웹사이트 방문자 데이터 수집 +GA4_API_TOKEN=your_ga4_api_token +GA4_PROPERTY_ID=384052726 +GA4_SERVICE_ACCOUNT_FILE=./conf/service-account-credentials.json +GA4_START_DATE=20170101 +GA4_END_DATE=20990731 +GA4_MAX_ROWS_PER_REQUEST=10000 + +# ===== POS 시스템 설정 ===== +# 방문객 카테고리 (쉼표로 구분) +VISITOR_CATEGORIES=입장료,티켓,기업제휴 + +# UPSolution POS 연동 정보 +UPSOLUTION_ID=your_upsolution_id +UPSOLUTION_CODE=1112 +UPSOLUTION_PW=your_password + +# ===== 방문객 예측 모델 가중치 ===== +FORECAST_VISITOR_MULTIPLIER=0.5 +FORECAST_WEIGHT_MIN_TEMP=1.0 +FORECAST_WEIGHT_MAX_TEMP=1.0 +FORECAST_WEIGHT_PRECIPITATION=10.0 +FORECAST_WEIGHT_HUMIDITY=1.0 +FORECAST_WEIGHT_PM25=1.0 +FORECAST_WEIGHT_HOLIDAY=20 + +# 기존 데이터 덮어쓰기 여부 +FORCE_UPDATE=false + +# ===== Weather 서비스 설정 ===== +# 날씨 캡처 및 그누보드 연동용 + +# 기상청 API 서비스 키 (공공데이터포털) +SERVICE_KEY=your_service_key + +# FTP 설정 (이미지 업로드용) +FTP_HOST=your_ftp_host +FTP_USER=your_ftp_user +FTP_PASSWORD=your_ftp_password +FTP_UPLOAD_DIR=/path/to/upload + +# 그누보드 게시판 설정 +BOARD_ID=your_board_id +BOARD_CA_NAME=category_name +BOARD_CONTENT=게시판 기본 내용 +BOARD_MB_ID=admin +BOARD_NICKNAME=관리자 + +# ===== Mattermost 알림 설정 ===== +# 메시지 알림 서비스 연동 +MATTERMOST_URL=https://mattermost.example.com +MATTERMOST_TOKEN=your_bot_token +MATTERMOST_CHANNEL_ID=your_channel_id +MATTERMOST_WEBHOOK_URL=https://mattermost.example.com/hooks/xxx + +# ===== Telegram 알림 설정 (선택) ===== +TELEGRAM_BOT_TOKEN=your_telegram_bot_token +TELEGRAM_CHAT_ID=your_chat_id + +# ===== Synology Chat 설정 (선택) ===== +SYNOLOGY_CHAT_URL=https://your-synology.com/webapi/entry.cgi +SYNOLOGY_CHAT_TOKEN=your_token + +# ===== Notion API 설정 ===== +# Notion 웹훅 알림 연동 +NOTION_API_SECRET=your_notion_api_secret + +# ===== Flask 설정 ===== +FLASK_SECRET_KEY=your_secret_key_here +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f383cea --- /dev/null +++ b/.gitignore @@ -0,0 +1,83 @@ +# =================================================================== +# .gitignore - FGTools +# =================================================================== +# Git 추적에서 제외할 파일 및 폴더 목록 +# =================================================================== + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# 가상환경 +venv/ +.venv/ +ENV/ +env/ +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# 테스트/커버리지 +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover + +# 로그 +logs/ +*.log + +# 데이터 파일 +data/ +*.db +*.sqlite +*.sqlite3 + +# 임시 파일 +tmp/ +temp/ +*.tmp +*.bak + +# 업로드/출력 폴더 +uploads/ +output/ + +# OS 파일 +.DS_Store +Thumbs.db + +# 시크릿 파일 (절대 커밋 금지) +.env +*.pem +*.key +service-account-credentials.json +config.yaml + +# Docker 볼륨 +db_data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6215db6 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# =================================================================== +# FGTools - First Garden 통합 도구 +# =================================================================== +# 퍼스트가든 운영을 위한 통합 도구 모음입니다. +# 기상 데이터, 방문객 분석, 알림 등의 기능을 제공합니다. +# =================================================================== + +## 📌 개요 + +FGTools는 퍼스트가든 운영에 필요한 다양한 도구들을 통합한 프로젝트입니다. + +### 주요 기능 + +- **날씨 서비스**: 기상청 API를 통한 날씨 예보 및 ASOS 종관기상 데이터 수집 +- **분석 서비스**: Google Analytics 4, 대기질 데이터 수집 및 방문객 예측 +- **알림 서비스**: Notion 웹훅 처리 및 Mattermost/Telegram 알림 발송 +- **대시보드**: 수집된 데이터를 조회하고 시각화하는 웹 인터페이스 + +## 📁 프로젝트 구조 + +``` +fgtools/ +├── core/ # 핵심 공통 모듈 +│ ├── config.py # 통합 설정 관리 +│ ├── database.py # 데이터베이스 연결 관리 +│ ├── logging_utils.py # 로깅 유틸리티 +│ ├── http_client.py # HTTP 클라이언트 (재시도 지원) +│ └── message_sender.py # 다중 플랫폼 메시지 발송 +│ +├── services/ # 도메인 서비스 +│ ├── weather/ # 기상 데이터 서비스 +│ │ ├── forecast.py # 예보 API (초단기/단기/중기) +│ │ ├── asos.py # ASOS 종관기상 데이터 +│ │ └── precipitation.py # 강수량 서비스 +│ │ +│ ├── analytics/ # 분석 서비스 +│ │ ├── ga4.py # Google Analytics 4 +│ │ ├── air_quality.py # 대기질 데이터 +│ │ └── visitor_forecast.py # 방문객 예측 +│ │ +│ └── notification/ # 알림 서비스 +│ ├── notion.py # Notion 웹훅 처리 +│ └── mattermost.py # Mattermost 알림 +│ +├── apps/ # 웹 애플리케이션 +│ ├── dashboard/ # 대시보드 API +│ ├── weather_api/ # 날씨 API 서버 +│ └── webhook/ # 웹훅 수신 서버 +│ +├── .env.sample # 환경변수 샘플 +├── requirements.txt # Python 의존성 +└── docker-compose.yml # Docker 구성 +``` + +## 🚀 시작하기 + +### 1. 환경 설정 + +```bash +# 저장소 클론 +git clone https://git.siane.kr/firstgarden/fgtools.git +cd fgtools + +# 가상환경 생성 및 활성화 +python -m venv venv +source venv/bin/activate # Linux/Mac +# 또는 +.\venv\Scripts\activate # Windows + +# 의존성 설치 +pip install -r requirements.txt +``` + +### 2. 설정 파일 생성 + +```bash +# .env 파일 생성 +cp .env.sample .env + +# .env 파일을 편집하여 실제 값 입력 +# - 데이터베이스 접속 정보 +# - API 키 +# - 알림 설정 등 +``` + +### 3. 애플리케이션 실행 + +```bash +# 대시보드 서버 실행 +python -m apps.dashboard.app + +# 날씨 API 서버 실행 +python -m apps.weather_api.app + +# 웹훅 수신 서버 실행 +python -m apps.webhook.app +``` + +### 4. Docker로 실행 + +```bash +# Docker Compose로 모든 서비스 실행 +docker-compose up -d + +# 로그 확인 +docker-compose logs -f +``` + +## 📖 API 문서 + +### Dashboard API (포트 5000) + +| 엔드포인트 | 메서드 | 설명 | +|-----------|--------|------| +| `/api/dashboard/health` | GET | 헬스 체크 | +| `/api/dashboard/stats` | GET | 통계 조회 | +| `/api/dashboard/weather/forecast` | GET | 날씨 예보 | +| `/api/dashboard/weather/precipitation` | GET | 강수량 예보 | + +### Weather API (포트 5001) + +| 엔드포인트 | 메서드 | 설명 | +|-----------|--------|------| +| `/api/weather/health` | GET | 헬스 체크 | +| `/api/weather/precipitation` | GET | 시간별 강수량 | +| `/api/weather/forecast/ultra` | GET | 초단기예보 | +| `/api/weather/forecast/vilage` | GET | 단기예보 | +| `/api/weather/forecast/midterm` | GET | 중기예보 | + +### Webhook API (포트 5002) + +| 엔드포인트 | 메서드 | 설명 | +|-----------|--------|------| +| `/webhook/health` | GET | 헬스 체크 | +| `/webhook/notion` | POST | Notion 웹훅 수신 | +| `/webhook/notify` | POST | 알림 발송 | + +## ⚙️ 환경변수 설명 + +주요 환경변수는 `.env.sample` 파일을 참고하세요. + +| 변수명 | 설명 | 필수 | +|--------|------|------| +| `DB_HOST` | 데이터베이스 호스트 | ✅ | +| `DB_USER` | 데이터베이스 사용자 | ✅ | +| `DB_PASSWORD` | 데이터베이스 비밀번호 | ✅ | +| `DATA_API_SERVICE_KEY` | 공공데이터포털 API 키 | ✅ | +| `MATTERMOST_URL` | Mattermost 서버 URL | ❌ | +| `NOTION_API_SECRET` | Notion API 시크릿 | ❌ | + +## 🔧 개발 + +### 코드 스타일 + +- Python 3.10 이상 +- Type hints 사용 +- Docstring 필수 +- Black 포매터 권장 + +### 테스트 + +```bash +# 테스트 실행 +pytest tests/ + +# 커버리지 포함 +pytest --cov=. tests/ +``` + +## 📝 라이선스 + +Private - First Garden Internal Use Only + +## 📞 연락처 + +기술 지원: dev@firstgarden.kr diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..882cca8 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,15 @@ +# =================================================================== +# apps/__init__.py +# FGTools 애플리케이션 패키지 초기화 +# =================================================================== +# 웹 애플리케이션 엔드포인트들을 제공합니다: +# - dashboard: 정적 데이터 대시보드 +# - weather_api: 날씨 API 서버 +# - webhook: 웹훅 수신 서버 +# =================================================================== + +__all__ = [ + 'dashboard', + 'weather_api', + 'webhook', +] diff --git a/apps/dashboard/__init__.py b/apps/dashboard/__init__.py new file mode 100644 index 0000000..0b2fe39 --- /dev/null +++ b/apps/dashboard/__init__.py @@ -0,0 +1,8 @@ +# =================================================================== +# apps/dashboard/__init__.py +# 대시보드 앱 패키지 초기화 +# =================================================================== + +from .app import create_app, dashboard_bp, run_server + +__all__ = ['create_app', 'dashboard_bp', 'run_server'] diff --git a/apps/dashboard/app.py b/apps/dashboard/app.py new file mode 100644 index 0000000..8669734 --- /dev/null +++ b/apps/dashboard/app.py @@ -0,0 +1,205 @@ +# =================================================================== +# apps/dashboard/app.py +# 정적 데이터 대시보드 웹 애플리케이션 +# =================================================================== +# 방문객, 날씨, 대기질, 예측 데이터를 시각화하는 대시보드입니다. +# Flask Blueprint 기반으로 구현되어 통합 앱에 포함할 수 있습니다. +# =================================================================== +""" +정적 데이터 대시보드 웹 애플리케이션 + +방문객 통계, 날씨 정보, 대기질 데이터 등을 조회하고 +시각화하는 대시보드를 제공합니다. + +사용 예시: + from apps.dashboard.app import create_app + + app = create_app() + app.run() +""" + +import os +from flask import Flask, Blueprint, jsonify, request, render_template +from werkzeug.exceptions import HTTPException + +from core.config import get_config +from core.logging_utils import get_logger, setup_logging +from core.database import get_engine, DBSession + +logger = get_logger(__name__) + +# Blueprint 생성 +dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/api/dashboard') + + +@dashboard_bp.route('/health', methods=['GET']) +def health_check(): + """ + 헬스 체크 엔드포인트 + + Returns: + 상태 정보 JSON + """ + return jsonify({ + 'status': 'healthy', + 'service': 'dashboard' + }) + + +@dashboard_bp.route('/stats', methods=['GET']) +def get_stats(): + """ + 기본 통계 조회 + + Query Parameters: + start_date: 시작 날짜 (YYYY-MM-DD) + end_date: 종료 날짜 (YYYY-MM-DD) + + Returns: + 통계 데이터 JSON + """ + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + # TODO: 실제 통계 조회 구현 + return jsonify({ + 'start_date': start_date, + 'end_date': end_date, + 'total_visitors': 0, + 'average_visitors': 0, + 'data': [] + }) + + +@dashboard_bp.route('/weather/forecast', methods=['GET']) +def get_weather_forecast(): + """ + 날씨 예보 조회 + + Returns: + 날씨 예보 데이터 JSON + """ + from services.weather import get_daily_vilage_forecast + + config = get_config() + service_key = config.data_api.get('service_key', '') + + if not service_key: + return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500 + + try: + forecast = get_daily_vilage_forecast(service_key) + return jsonify({ + 'status': 'success', + 'data': forecast + }) + except Exception as e: + logger.error(f"날씨 예보 조회 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@dashboard_bp.route('/weather/precipitation', methods=['GET']) +def get_precipitation(): + """ + 강수량 예보 조회 + + Query Parameters: + format: 출력 형식 (json, html, text) + + Returns: + 강수량 데이터 + """ + from services.weather import PrecipitationService + + output_format = request.args.get('format', 'json') + + try: + service = PrecipitationService() + data = service.get_precipitation_info(output_format=output_format) + + if output_format == 'html': + return data, 200, {'Content-Type': 'text/html; charset=utf-8'} + elif output_format == 'text': + return data, 200, {'Content-Type': 'text/plain; charset=utf-8'} + else: + return jsonify(data) + + except Exception as e: + logger.error(f"강수량 조회 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@dashboard_bp.errorhandler(HTTPException) +def handle_exception(e): + """HTTP 예외 핸들러""" + return jsonify({ + 'error': e.description, + 'status_code': e.code + }), e.code + + +def create_app(config_override: dict = None) -> Flask: + """ + Flask 애플리케이션 팩토리 + + Args: + config_override: 설정 덮어쓰기 딕셔너리 + + Returns: + Flask 앱 인스턴스 + """ + app = Flask(__name__) + + # 설정 로드 + config = get_config() + + app.config['SECRET_KEY'] = config.flask.get('secret_key', 'dev-secret') + app.config['JSON_AS_ASCII'] = False + + if config_override: + app.config.update(config_override) + + # 로깅 설정 + setup_logging('apps.dashboard', level=config.log_level) + + # Blueprint 등록 + app.register_blueprint(dashboard_bp) + + # 루트 엔드포인트 + @app.route('/') + def index(): + return jsonify({ + 'name': 'FGTools Dashboard API', + 'version': '1.0.0', + 'endpoints': [ + '/api/dashboard/health', + '/api/dashboard/stats', + '/api/dashboard/weather/forecast', + '/api/dashboard/weather/precipitation', + ] + }) + + logger.info("Dashboard 앱 초기화 완료") + return app + + +def run_server(host: str = '0.0.0.0', port: int = 5000, debug: bool = False): + """ + 개발 서버 실행 + + Args: + host: 바인딩 호스트 + port: 포트 번호 + debug: 디버그 모드 + """ + app = create_app() + app.run(host=host, port=port, debug=debug) + + +if __name__ == '__main__': + config = get_config() + run_server( + host=config.flask.get('host', '0.0.0.0'), + port=config.flask.get('port', 5000), + debug=config.debug + ) diff --git a/apps/weather_api/__init__.py b/apps/weather_api/__init__.py new file mode 100644 index 0000000..3449f39 --- /dev/null +++ b/apps/weather_api/__init__.py @@ -0,0 +1,8 @@ +# =================================================================== +# apps/weather_api/__init__.py +# 날씨 API 앱 패키지 초기화 +# =================================================================== + +from .app import create_app, weather_bp, run_server + +__all__ = ['create_app', 'weather_bp', 'run_server'] diff --git a/apps/weather_api/app.py b/apps/weather_api/app.py new file mode 100644 index 0000000..84a621a --- /dev/null +++ b/apps/weather_api/app.py @@ -0,0 +1,243 @@ +# =================================================================== +# apps/weather_api/app.py +# 날씨 API 서버 애플리케이션 +# =================================================================== +# 기상청 API 데이터를 제공하는 REST API 서버입니다. +# 강수량 예보, 날씨 캡처 등의 기능을 제공합니다. +# =================================================================== +""" +날씨 API 서버 애플리케이션 + +기상청 API 데이터를 조회하고 가공하여 제공하는 API 서버입니다. + +사용 예시: + from apps.weather_api.app import create_app + + app = create_app() + app.run() +""" + +from flask import Flask, Blueprint, jsonify, request +from werkzeug.exceptions import HTTPException + +from core.config import get_config +from core.logging_utils import get_logger, setup_logging + +logger = get_logger(__name__) + +# Blueprint 생성 +weather_bp = Blueprint('weather', __name__, url_prefix='/api/weather') + + +@weather_bp.route('/health', methods=['GET']) +def health_check(): + """헬스 체크""" + return jsonify({ + 'status': 'healthy', + 'service': 'weather-api' + }) + + +@weather_bp.route('/precipitation', methods=['GET']) +def get_precipitation(): + """ + 시간별 강수량 예보 조회 + + Query Parameters: + date: 조회 날짜 (YYYYMMDD, 기본: 오늘) + format: 출력 형식 (json, html, text) + + Returns: + 강수량 예보 데이터 + """ + from services.weather import PrecipitationService + + target_date = request.args.get('date') + output_format = request.args.get('format', 'json') + + try: + service = PrecipitationService() + data = service.get_precipitation_info( + target_date=target_date, + output_format=output_format + ) + + if output_format == 'html': + return data, 200, {'Content-Type': 'text/html; charset=utf-8'} + elif output_format == 'text': + return data, 200, {'Content-Type': 'text/plain; charset=utf-8'} + else: + return jsonify({ + 'status': 'success', + 'data': data + }) + + except Exception as e: + logger.error(f"강수량 조회 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@weather_bp.route('/forecast/ultra', methods=['GET']) +def get_ultra_forecast(): + """ + 초단기예보 조회 + + Returns: + 초단기예보 데이터 + """ + from services.weather import get_daily_ultra_forecast + + config = get_config() + service_key = config.data_api.get('service_key') or config.weather_service.get('service_key', '') + + if not service_key: + return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500 + + try: + data = get_daily_ultra_forecast(service_key) + return jsonify({ + 'status': 'success', + 'type': 'ultra', + 'data': data + }) + except Exception as e: + logger.error(f"초단기예보 조회 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@weather_bp.route('/forecast/vilage', methods=['GET']) +def get_vilage_forecast(): + """ + 단기예보 조회 + + Returns: + 단기예보 데이터 + """ + from services.weather import get_daily_vilage_forecast + + config = get_config() + service_key = config.data_api.get('service_key') or config.weather_service.get('service_key', '') + + if not service_key: + return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500 + + try: + data = get_daily_vilage_forecast(service_key) + return jsonify({ + 'status': 'success', + 'type': 'vilage', + 'data': data + }) + except Exception as e: + logger.error(f"단기예보 조회 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@weather_bp.route('/forecast/midterm', methods=['GET']) +def get_midterm_forecast(): + """ + 중기예보 조회 + + Query Parameters: + reg_id: 지역 코드 (기본: 11B20305) + + Returns: + 중기예보 데이터 + """ + from services.weather import get_midterm_forecast as fetch_midterm + + config = get_config() + service_key = config.data_api.get('service_key') or config.weather_service.get('service_key', '') + reg_id = request.args.get('reg_id', '11B20305') + + if not service_key: + return jsonify({'error': 'API 키가 설정되지 않았습니다.'}), 500 + + try: + precip_probs, raw_data = fetch_midterm(service_key, reg_id) + return jsonify({ + 'status': 'success', + 'type': 'midterm', + 'precipitation_probability': precip_probs, + 'raw_data': raw_data + }) + except Exception as e: + logger.error(f"중기예보 조회 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@weather_bp.errorhandler(HTTPException) +def handle_exception(e): + """HTTP 예외 핸들러""" + return jsonify({ + 'error': e.description, + 'status_code': e.code + }), e.code + + +def create_app(config_override: dict = None) -> Flask: + """ + Flask 애플리케이션 팩토리 + + Args: + config_override: 설정 덮어쓰기 딕셔너리 + + Returns: + Flask 앱 인스턴스 + """ + app = Flask(__name__) + + # 설정 로드 + config = get_config() + + app.config['SECRET_KEY'] = config.flask.get('secret_key', 'dev-secret') + app.config['JSON_AS_ASCII'] = False + + if config_override: + app.config.update(config_override) + + # 로깅 설정 + setup_logging('apps.weather_api', level=config.log_level) + + # Blueprint 등록 + app.register_blueprint(weather_bp) + + # 루트 엔드포인트 + @app.route('/') + def index(): + return jsonify({ + 'name': 'FGTools Weather API', + 'version': '1.0.0', + 'endpoints': [ + '/api/weather/health', + '/api/weather/precipitation', + '/api/weather/forecast/ultra', + '/api/weather/forecast/vilage', + '/api/weather/forecast/midterm', + ] + }) + + logger.info("Weather API 앱 초기화 완료") + return app + + +def run_server(host: str = '0.0.0.0', port: int = 5001, debug: bool = False): + """ + 개발 서버 실행 + + Args: + host: 바인딩 호스트 + port: 포트 번호 + debug: 디버그 모드 + """ + app = create_app() + app.run(host=host, port=port, debug=debug) + + +if __name__ == '__main__': + config = get_config() + run_server( + host=config.flask.get('host', '0.0.0.0'), + port=5001, + debug=config.debug + ) diff --git a/apps/webhook/__init__.py b/apps/webhook/__init__.py new file mode 100644 index 0000000..328694b --- /dev/null +++ b/apps/webhook/__init__.py @@ -0,0 +1,8 @@ +# =================================================================== +# apps/webhook/__init__.py +# 웹훅 수신 앱 패키지 초기화 +# =================================================================== + +from .app import create_app, webhook_bp, run_server + +__all__ = ['create_app', 'webhook_bp', 'run_server'] diff --git a/apps/webhook/app.py b/apps/webhook/app.py new file mode 100644 index 0000000..fdc8923 --- /dev/null +++ b/apps/webhook/app.py @@ -0,0 +1,238 @@ +# =================================================================== +# apps/webhook/app.py +# 웹훅 수신 서버 애플리케이션 +# =================================================================== +# Notion 등 외부 서비스의 웹훅을 수신하고 처리합니다. +# Mattermost 등으로 알림을 발송합니다. +# =================================================================== +""" +웹훅 수신 서버 애플리케이션 + +Notion 등 외부 서비스의 웹훅을 수신하고 +Mattermost 등으로 알림을 발송합니다. + +사용 예시: + from apps.webhook.app import create_app + + app = create_app() + app.run() +""" + +from flask import Flask, Blueprint, jsonify, request +from werkzeug.exceptions import HTTPException + +from core.config import get_config +from core.logging_utils import get_logger, setup_logging +from services.notification import NotionWebhookHandler, MattermostNotifier + +logger = get_logger(__name__) + +# Blueprint 생성 +webhook_bp = Blueprint('webhook', __name__, url_prefix='/webhook') + + +@webhook_bp.route('/health', methods=['GET']) +def health_check(): + """헬스 체크""" + return jsonify({ + 'status': 'healthy', + 'service': 'webhook-receiver' + }) + + +@webhook_bp.route('/notion', methods=['POST']) +def handle_notion_webhook(): + """ + Notion 웹훅 수신 엔드포인트 + + Notion에서 발생한 이벤트를 수신하고 + Mattermost로 알림을 발송합니다. + + Returns: + 처리 결과 JSON + """ + # JSON 데이터 파싱 + try: + event_data = request.get_json() + except Exception as e: + logger.error(f"JSON 파싱 실패: {e}") + return jsonify({'error': 'Invalid JSON'}), 400 + + if not event_data: + return jsonify({'error': 'Empty request body'}), 400 + + # 이벤트 처리 + try: + handler = NotionWebhookHandler() + message = handler.handle_event(event_data) + + if message: + # Mattermost로 알림 발송 + notifier = MattermostNotifier.from_config() + sent = notifier.send_message(message) + + if sent: + logger.info("Notion 이벤트 처리 및 알림 발송 완료") + return jsonify({ + 'status': 'success', + 'message_sent': True + }) + else: + logger.warning("알림 발송 실패") + return jsonify({ + 'status': 'partial', + 'message_sent': False, + 'reason': 'Message delivery failed' + }) + else: + logger.info("이벤트 무시됨 (필터 조건 불충족)") + return jsonify({ + 'status': 'ignored', + 'reason': 'Event filtered out' + }) + + except Exception as e: + logger.error(f"이벤트 처리 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@webhook_bp.route('/test', methods=['POST']) +def test_webhook(): + """ + 테스트 웹훅 엔드포인트 + + 수신된 데이터를 그대로 반환합니다. + 디버깅 용도로 사용합니다. + """ + data = request.get_json() or {} + + logger.info(f"테스트 웹훅 수신: {data}") + + return jsonify({ + 'status': 'received', + 'data': data, + 'headers': dict(request.headers) + }) + + +@webhook_bp.route('/notify', methods=['POST']) +def send_notification(): + """ + 알림 발송 엔드포인트 + + 직접 메시지를 발송할 수 있는 엔드포인트입니다. + + Request Body: + message: 발송할 메시지 (필수) + platform: 발송 플랫폼 (mattermost, telegram, synology) + channel_id: 채널 ID (선택) + + Returns: + 발송 결과 JSON + """ + try: + data = request.get_json() + except Exception: + return jsonify({'error': 'Invalid JSON'}), 400 + + if not data or 'message' not in data: + return jsonify({'error': 'Message is required'}), 400 + + message = data['message'] + platform = data.get('platform', 'mattermost') + channel_id = data.get('channel_id') + + try: + if platform == 'mattermost': + notifier = MattermostNotifier.from_config() + success = notifier.send_message(message, channel_id=channel_id) + else: + # 다른 플랫폼은 MessageSender 사용 + from core.message_sender import MessageSender + sender = MessageSender.from_config() + success = sender.send(message, platforms=[platform]) + + return jsonify({ + 'status': 'success' if success else 'failed', + 'platform': platform + }) + + except Exception as e: + logger.error(f"알림 발송 실패: {e}") + return jsonify({'error': str(e)}), 500 + + +@webhook_bp.errorhandler(HTTPException) +def handle_exception(e): + """HTTP 예외 핸들러""" + return jsonify({ + 'error': e.description, + 'status_code': e.code + }), e.code + + +def create_app(config_override: dict = None) -> Flask: + """ + Flask 애플리케이션 팩토리 + + Args: + config_override: 설정 덮어쓰기 딕셔너리 + + Returns: + Flask 앱 인스턴스 + """ + app = Flask(__name__) + + # 설정 로드 + config = get_config() + + app.config['SECRET_KEY'] = config.flask.get('secret_key', 'dev-secret') + app.config['JSON_AS_ASCII'] = False + + if config_override: + app.config.update(config_override) + + # 로깅 설정 + setup_logging('apps.webhook', level=config.log_level) + + # Blueprint 등록 + app.register_blueprint(webhook_bp) + + # 루트 엔드포인트 + @app.route('/') + def index(): + return jsonify({ + 'name': 'FGTools Webhook Receiver', + 'version': '1.0.0', + 'endpoints': [ + '/webhook/health', + '/webhook/notion', + '/webhook/test', + '/webhook/notify', + ] + }) + + logger.info("Webhook 앱 초기화 완료") + return app + + +def run_server(host: str = '0.0.0.0', port: int = 5002, debug: bool = False): + """ + 개발 서버 실행 + + Args: + host: 바인딩 호스트 + port: 포트 번호 + debug: 디버그 모드 + """ + app = create_app() + app.run(host=host, port=port, debug=debug) + + +if __name__ == '__main__': + config = get_config() + run_server( + host=config.flask.get('host', '0.0.0.0'), + port=5002, + debug=config.debug + ) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..506c590 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,29 @@ +# =================================================================== +# core/__init__.py +# FGTools 핵심 모듈 패키지 초기화 +# =================================================================== +# 공통으로 사용되는 핵심 모듈들을 제공합니다: +# - config: 환경설정 관리 +# - database: 데이터베이스 연결 및 세션 관리 +# - logging_utils: 로깅 유틸리티 +# - http_client: HTTP 요청 재시도 클라이언트 +# - message_sender: 다중 플랫폼 메시지 발송 +# =================================================================== + +from .config import Config, get_config +from .database import get_engine, get_session, DBSession +from .logging_utils import setup_logging, get_logger +from .http_client import create_retry_session +from .message_sender import MessageSender + +__all__ = [ + 'Config', + 'get_config', + 'get_engine', + 'get_session', + 'DBSession', + 'setup_logging', + 'get_logger', + 'create_retry_session', + 'MessageSender', +] diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..7b457a2 --- /dev/null +++ b/core/config.py @@ -0,0 +1,267 @@ +# =================================================================== +# core/config.py +# FGTools 통합 설정 관리 모듈 +# =================================================================== +# 환경변수(.env)를 기반으로 모든 설정을 통합 관리합니다. +# python-dotenv를 사용하여 .env 파일을 자동으로 로드합니다. +# =================================================================== +""" +FGTools 설정 관리 모듈 + +환경변수 기반의 통합 설정 관리를 제공합니다. +.env 파일에서 설정을 로드하고, 기본값 및 타입 변환을 지원합니다. + +사용 예시: + from core.config import get_config + config = get_config() + db_host = config.database['host'] +""" + +import os +import sys +from typing import Any, Dict, List, Optional +from functools import lru_cache + +# .env 파일 로드 +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + + +class Config: + """ + 환경변수 기반 설정 관리 클래스 + + 모든 설정은 환경변수에서 로드되며, 기본값을 제공합니다. + 민감한 정보는 .env 파일에 저장하고 .gitignore에 추가하세요. + """ + + def __init__(self): + """설정 초기화 및 환경변수 로드""" + self._load_all_configs() + + def _get_env(self, key: str, default: str = '', required: bool = False) -> str: + """ + 환경변수 조회 + + Args: + key: 환경변수 키 + default: 기본값 + required: 필수 여부 (True면 없을 시 에러) + + Returns: + 환경변수 값 + + Raises: + SystemExit: 필수 환경변수가 없을 경우 + """ + value = os.getenv(key, default) + if required and not value: + print(f"[ERROR] 필수 환경변수가 설정되지 않았습니다: {key}") + sys.exit(1) + return value + + def _get_bool(self, key: str, default: bool = False) -> bool: + """환경변수를 불리언으로 변환""" + return self._get_env(key, str(default)).lower() in ('true', '1', 'yes') + + def _get_int(self, key: str, default: int = 0) -> int: + """환경변수를 정수로 변환""" + try: + return int(self._get_env(key, str(default))) + except ValueError: + return default + + def _get_float(self, key: str, default: float = 0.0) -> float: + """환경변수를 실수로 변환""" + try: + return float(self._get_env(key, str(default))) + except ValueError: + return default + + def _get_list(self, key: str, default: str = '', separator: str = ',') -> List[str]: + """환경변수를 리스트로 변환 (구분자로 분리)""" + value = self._get_env(key, default) + return [item.strip() for item in value.split(separator) if item.strip()] + + def _get_int_list(self, key: str, default: str = '') -> List[int]: + """환경변수를 정수 리스트로 변환""" + str_list = self._get_list(key, default) + result = [] + for item in str_list: + try: + result.append(int(item)) + except ValueError: + continue + return result + + def _load_all_configs(self): + """모든 설정 로드""" + # 공통 설정 + self.debug = self._get_bool('DEBUG', False) + self.log_level = self._get_env('LOG_LEVEL', 'INFO') + self.max_workers = self._get_int('MAX_WORKERS', 4) + + # 데이터베이스 설정 + self.database = { + 'host': self._get_env('DB_HOST', 'localhost'), + 'user': self._get_env('DB_USER', 'firstgarden'), + 'password': self._get_env('DB_PASSWORD', ''), + 'name': self._get_env('DB_NAME', 'firstgarden'), + 'charset': self._get_env('DB_CHARSET', 'utf8mb4'), + } + + self.table_prefix = self._get_env('TABLE_PREFIX', 'fg_manager_static_') + + # 공공데이터포털 API 설정 + self.data_api = { + 'service_key': self._get_env('DATA_API_SERVICE_KEY', ''), + 'start_date': self._get_env('DATA_API_START_DATE', '20170101'), + 'end_date': self._get_env('DATA_API_END_DATE', '20250701'), + 'air_stations': self._get_list('AIR_STATION_NAMES', '운정'), + 'weather_station_ids': self._get_int_list('WEATHER_STN_IDS', '99'), + } + + # GA4 설정 + self.ga4 = { + 'api_token': self._get_env('GA4_API_TOKEN', ''), + 'property_id': self._get_int('GA4_PROPERTY_ID', 0), + 'service_account_file': self._get_env('GA4_SERVICE_ACCOUNT_FILE', './conf/service-account-credentials.json'), + 'start_date': self._get_env('GA4_START_DATE', '20170101'), + 'end_date': self._get_env('GA4_END_DATE', '20990731'), + 'max_rows_per_request': self._get_int('GA4_MAX_ROWS_PER_REQUEST', 10000), + } + + # POS 설정 + self.pos = { + 'visitor_categories': self._get_list('VISITOR_CATEGORIES', '입장료,티켓,기업제휴'), + } + + # UPSolution 설정 + self.upsolution = { + 'id': self._get_env('UPSOLUTION_ID', ''), + 'code': self._get_env('UPSOLUTION_CODE', ''), + 'pw': self._get_env('UPSOLUTION_PW', ''), + } + + # 예측 가중치 설정 + self.forecast_weight = { + 'visitor_multiplier': self._get_float('FORECAST_VISITOR_MULTIPLIER', 0.5), + 'min_temp': self._get_float('FORECAST_WEIGHT_MIN_TEMP', 1.0), + 'max_temp': self._get_float('FORECAST_WEIGHT_MAX_TEMP', 1.0), + 'precipitation': self._get_float('FORECAST_WEIGHT_PRECIPITATION', 10.0), + 'humidity': self._get_float('FORECAST_WEIGHT_HUMIDITY', 1.0), + 'pm25': self._get_float('FORECAST_WEIGHT_PM25', 1.0), + 'holiday': self._get_int('FORECAST_WEIGHT_HOLIDAY', 20), + } + + self.force_update = self._get_bool('FORCE_UPDATE', False) + + # Weather 서비스 설정 + self.weather_service = { + 'service_key': self._get_env('SERVICE_KEY', ''), + } + + # FTP 설정 + self.ftp = { + 'host': self._get_env('FTP_HOST', ''), + 'user': self._get_env('FTP_USER', ''), + 'password': self._get_env('FTP_PASSWORD', ''), + 'upload_dir': self._get_env('FTP_UPLOAD_DIR', ''), + } + + # 게시판 설정 + self.board = { + 'id': self._get_env('BOARD_ID', ''), + 'ca_name': self._get_env('BOARD_CA_NAME', ''), + 'content': self._get_env('BOARD_CONTENT', ''), + 'mb_id': self._get_env('BOARD_MB_ID', ''), + 'nickname': self._get_env('BOARD_NICKNAME', ''), + } + + # Mattermost 설정 + self.mattermost = { + 'url': self._get_env('MATTERMOST_URL', ''), + 'token': self._get_env('MATTERMOST_TOKEN', ''), + 'channel_id': self._get_env('MATTERMOST_CHANNEL_ID', ''), + 'webhook_url': self._get_env('MATTERMOST_WEBHOOK_URL', ''), + } + + # Telegram 설정 + self.telegram = { + 'bot_token': self._get_env('TELEGRAM_BOT_TOKEN', ''), + 'chat_id': self._get_env('TELEGRAM_CHAT_ID', ''), + } + + # Synology Chat 설정 + self.synology = { + 'chat_url': self._get_env('SYNOLOGY_CHAT_URL', ''), + 'chat_token': self._get_env('SYNOLOGY_CHAT_TOKEN', ''), + } + + # Notion 설정 + self.notion = { + 'api_secret': self._get_env('NOTION_API_SECRET', ''), + } + + # Flask 설정 + self.flask = { + 'secret_key': self._get_env('FLASK_SECRET_KEY', 'dev-secret-key'), + 'host': self._get_env('FLASK_HOST', '0.0.0.0'), + 'port': self._get_int('FLASK_PORT', 5000), + } + + def get(self, key: str, default: Any = None) -> Any: + """ + 설정값 조회 (딕셔너리 스타일) + + Args: + key: 설정 키 (점 표기법 지원, 예: 'database.host') + default: 기본값 + + Returns: + 설정값 + """ + keys = key.split('.') + value = self + for k in keys: + if hasattr(value, k): + value = getattr(value, k) + elif isinstance(value, dict) and k in value: + value = value[k] + else: + return default + return value + + +# 싱글톤 인스턴스 +_config_instance: Optional[Config] = None + + +@lru_cache(maxsize=1) +def get_config() -> Config: + """ + 설정 싱글톤 인스턴스 반환 + + 처음 호출 시 Config 인스턴스를 생성하고 캐시합니다. + 이후 호출에서는 캐시된 인스턴스를 반환합니다. + + Returns: + Config 인스턴스 + """ + return Config() + + +def reload_config() -> Config: + """ + 설정 다시 로드 (캐시 무효화) + + 환경변수가 변경된 후 설정을 다시 로드할 때 사용합니다. + + Returns: + 새로운 Config 인스턴스 + """ + get_config.cache_clear() + return get_config() diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..c8067bf --- /dev/null +++ b/core/database.py @@ -0,0 +1,280 @@ +# =================================================================== +# core/database.py +# FGTools 데이터베이스 연결 관리 모듈 +# =================================================================== +# SQLAlchemy를 사용하여 MySQL/MariaDB 연결을 관리합니다. +# 연결 풀링, 자동 재연결, 컨텍스트 매니저를 지원합니다. +# =================================================================== +""" +데이터베이스 연결 관리 모듈 + +SQLAlchemy 기반의 MySQL/MariaDB 연결 관리를 제공합니다. +연결 풀링, 자동 재연결, 트랜잭션 관리를 지원합니다. + +사용 예시: + from core.database import DBSession, get_engine + + # 컨텍스트 매니저 사용 (권장) + with DBSession() as session: + result = session.execute(text("SELECT 1")) + + # 엔진 직접 사용 + engine = get_engine() +""" + +import logging +from typing import Optional +from contextlib import contextmanager + +from sqlalchemy import create_engine, text, event, exc +from sqlalchemy.pool import QueuePool +from sqlalchemy.orm import sessionmaker, scoped_session, Session + +from .config import get_config + +logger = logging.getLogger(__name__) + +# 엔진 및 세션 팩토리 캐시 +_engine = None +_session_factory = None +_scoped_session = None + + +def _build_db_url() -> str: + """ + 데이터베이스 연결 URL 생성 + + Returns: + SQLAlchemy 연결 URL 문자열 + """ + config = get_config() + db = config.database + + return ( + f"mysql+pymysql://{db['user']}:{db['password']}" + f"@{db['host']}/{db['name']}?charset={db['charset']}" + ) + + +def get_engine(): + """ + SQLAlchemy 엔진 반환 (싱글톤) + + 연결 풀 설정: + - pool_size: 기본 연결 수 (10) + - max_overflow: 추가 가능한 연결 수 (20) + - pool_recycle: 연결 재활용 시간 (1시간) + - pool_pre_ping: 연결 전 상태 확인 + + Returns: + SQLAlchemy Engine 인스턴스 + """ + global _engine + + if _engine is None: + db_url = _build_db_url() + + _engine = create_engine( + db_url, + poolclass=QueuePool, + pool_pre_ping=True, # 연결 전 핸들 확인 (끊어진 연결 방지) + pool_recycle=3600, # 1시간마다 연결 재생성 + pool_size=10, # 기본 연결 풀 크기 + max_overflow=20, # 추가 오버플로우 연결 수 + echo=get_config().debug, # 디버그 모드에서만 SQL 출력 + connect_args={ + 'connect_timeout': 10, + 'charset': 'utf8mb4' + } + ) + + # 연결 이벤트 리스너 등록 + _setup_event_listeners() + + logger.info("데이터베이스 엔진 초기화 완료") + + return _engine + + +def _setup_event_listeners(): + """SQLAlchemy 이벤트 리스너 설정""" + + @event.listens_for(_engine, "connect") + def on_connect(dbapi_conn, connection_record): + """데이터베이스 연결 성공 시 호출""" + logger.debug("DB 연결 성공") + + @event.listens_for(_engine, "checkout") + def on_checkout(dbapi_conn, connection_record, connection_proxy): + """연결 풀에서 연결을 가져올 때 호출""" + logger.debug("DB 연결 체크아웃") + + @event.listens_for(_engine, "checkin") + def on_checkin(dbapi_conn, connection_record): + """연결을 풀에 반환할 때 호출""" + logger.debug("DB 연결 체크인") + + +def get_session_factory(): + """ + 세션 팩토리 반환 + + Returns: + SQLAlchemy sessionmaker 인스턴스 + """ + global _session_factory + + if _session_factory is None: + _session_factory = sessionmaker(bind=get_engine()) + + return _session_factory + + +def get_scoped_session(): + """ + 스레드 안전한 스코프 세션 반환 + + 멀티스레드 환경에서 각 스레드가 독립적인 세션을 사용하도록 보장합니다. + + Returns: + SQLAlchemy scoped_session 인스턴스 + """ + global _scoped_session + + if _scoped_session is None: + _scoped_session = scoped_session(get_session_factory()) + + return _scoped_session + + +def get_session() -> Session: + """ + 새로운 데이터베이스 세션 생성 + + Returns: + SQLAlchemy Session 인스턴스 + + Raises: + exc.DatabaseError: 연결 실패 시 + """ + session = get_session_factory()() + + try: + # 연결 테스트 + session.execute(text('SELECT 1')) + except exc.DatabaseError as e: + logger.error(f"DB 연결 실패: {e}") + session.close() + raise + + return session + + +def close_session(): + """스코프 세션 종료 및 정리""" + global _scoped_session + + if _scoped_session is not None: + _scoped_session.remove() + + +class DBSession: + """ + 데이터베이스 세션 컨텍스트 매니저 + + with 문을 사용하여 세션의 생성, 커밋/롤백, 종료를 자동으로 관리합니다. + + 사용 예시: + with DBSession() as session: + result = session.execute(text("SELECT * FROM users")) + for row in result: + print(row) + + Attributes: + session: SQLAlchemy Session 인스턴스 + """ + + def __init__(self, auto_commit: bool = True): + """ + Args: + auto_commit: 정상 종료 시 자동 커밋 여부 (기본: True) + """ + self.session: Optional[Session] = None + self.auto_commit = auto_commit + + def __enter__(self) -> Session: + """세션 생성 및 반환""" + self.session = get_session() + return self.session + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + 세션 종료 처리 + + 예외 발생 시 롤백, 정상 종료 시 커밋(auto_commit=True인 경우) + """ + if self.session is not None: + if exc_type is not None: + # 예외 발생 시 롤백 + self.session.rollback() + logger.error(f"트랜잭션 롤백: {exc_type.__name__}: {exc_val}") + elif self.auto_commit: + # 정상 종료 시 커밋 + try: + self.session.commit() + except Exception as e: + self.session.rollback() + logger.error(f"커밋 실패, 롤백 수행: {e}") + raise + + self.session.close() + + # 예외 전파하지 않음 (False 반환) + return False + + +@contextmanager +def db_transaction(): + """ + 데이터베이스 트랜잭션 컨텍스트 매니저 (함수형) + + DBSession 클래스와 동일한 기능을 함수형으로 제공합니다. + + 사용 예시: + with db_transaction() as session: + session.execute(text("INSERT INTO users (name) VALUES (:name)"), {"name": "test"}) + + Yields: + SQLAlchemy Session 인스턴스 + """ + session = get_session() + try: + yield session + session.commit() + except Exception as e: + session.rollback() + logger.error(f"트랜잭션 실패: {e}") + raise + finally: + session.close() + + +def dispose_engine(): + """ + 엔진 및 연결 풀 정리 + + 애플리케이션 종료 시 또는 연결 초기화가 필요할 때 호출합니다. + """ + global _engine, _session_factory, _scoped_session + + if _scoped_session is not None: + _scoped_session.remove() + _scoped_session = None + + if _engine is not None: + _engine.dispose() + _engine = None + + _session_factory = None + + logger.info("데이터베이스 엔진 정리 완료") diff --git a/core/http_client.py b/core/http_client.py new file mode 100644 index 0000000..2151cde --- /dev/null +++ b/core/http_client.py @@ -0,0 +1,332 @@ +# =================================================================== +# core/http_client.py +# FGTools HTTP 클라이언트 유틸리티 모듈 +# =================================================================== +# 자동 재시도, 타임아웃, 연결 풀링을 지원하는 HTTP 클라이언트입니다. +# requests 라이브러리 기반으로 안정적인 API 호출을 제공합니다. +# =================================================================== +""" +HTTP 클라이언트 유틸리티 모듈 + +자동 재시도, 지수 백오프, 타임아웃 설정을 지원하는 +안정적인 HTTP 클라이언트를 제공합니다. + +사용 예시: + from core.http_client import create_retry_session, get_json + + # 재시도 세션 사용 + session = create_retry_session(retries=3) + response = session.get("https://api.example.com/data") + + # 간편 JSON 요청 + data = get_json("https://api.example.com/data", params={"key": "value"}) +""" + +import time +import logging +from typing import Any, Dict, Optional, Callable +from functools import wraps + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from .logging_utils import get_logger + +logger = get_logger(__name__) + +# 기본 설정 상수 +DEFAULT_TIMEOUT = 30 +DEFAULT_RETRIES = 3 +DEFAULT_BACKOFF_FACTOR = 0.5 +RETRY_STATUS_CODES = [429, 500, 502, 503, 504] + + +def create_retry_session( + retries: int = DEFAULT_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, + status_forcelist: Optional[list] = None, + timeout: int = DEFAULT_TIMEOUT +) -> requests.Session: + """ + 자동 재시도를 지원하는 requests 세션 생성 + + 실패한 요청을 지수 백오프 방식으로 자동 재시도합니다. + 연결 풀링을 통해 다수의 요청을 효율적으로 처리합니다. + + Args: + retries: 최대 재시도 횟수 + backoff_factor: 재시도 간격 배수 (0.5면 0.5, 1, 2초...) + status_forcelist: 재시도할 HTTP 상태 코드 목록 + timeout: 요청 타임아웃 (초) + + Returns: + 설정된 requests.Session 인스턴스 + + 사용 예시: + session = create_retry_session(retries=5, timeout=60) + response = session.get("https://api.example.com/data") + """ + if status_forcelist is None: + status_forcelist = RETRY_STATUS_CODES + + session = requests.Session() + + # 재시도 전략 설정 + retry_strategy = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"], + raise_on_status=False + ) + + # HTTP/HTTPS 어댑터에 재시도 전략 적용 + adapter = HTTPAdapter( + max_retries=retry_strategy, + pool_connections=10, + pool_maxsize=10 + ) + session.mount("http://", adapter) + session.mount("https://", adapter) + + # 기본 타임아웃 설정 (request 메서드 래핑) + original_request = session.request + + def request_with_timeout(*args, **kwargs): + kwargs.setdefault('timeout', timeout) + return original_request(*args, **kwargs) + + session.request = request_with_timeout + + return session + + +def retry_on_exception( + max_retries: int = DEFAULT_RETRIES, + delay: float = 1.0, + backoff: float = 2.0, + exceptions: tuple = (Exception,) +) -> Callable: + """ + 함수 실행 실패 시 재시도하는 데코레이터 + + 지정된 예외가 발생하면 지수 백오프 방식으로 재시도합니다. + + Args: + max_retries: 최대 재시도 횟수 + delay: 초기 재시도 지연 시간 (초) + backoff: 재시도마다 지연 시간 배수 + exceptions: 재시도할 예외 타입들 + + Returns: + 데코레이터 함수 + + 사용 예시: + @retry_on_exception(max_retries=3, delay=1.0) + def fetch_data(): + return requests.get("https://api.example.com").json() + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + last_exception = None + + for attempt in range(max_retries): + try: + return func(*args, **kwargs) + except exceptions as e: + last_exception = e + if attempt < max_retries - 1: + wait_time = delay * (backoff ** attempt) + logger.warning( + f"{func.__name__} 재시도 {attempt + 1}/{max_retries} " + f"({wait_time:.1f}초 대기): {e}" + ) + time.sleep(wait_time) + else: + logger.error(f"{func.__name__} 모든 재시도 실패: {e}") + + raise last_exception + + return wrapper + return decorator + + +def get_json( + url: str, + params: Optional[Dict] = None, + headers: Optional[Dict] = None, + timeout: int = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES +) -> Optional[Dict]: + """ + JSON API 요청 간편 함수 + + GET 요청을 보내고 JSON 응답을 파싱하여 반환합니다. + 실패 시 None을 반환하고 에러를 로깅합니다. + + Args: + url: 요청 URL + params: 쿼리 파라미터 + headers: 요청 헤더 + timeout: 타임아웃 (초) + retries: 최대 재시도 횟수 + + Returns: + JSON 응답 딕셔너리 또는 None + """ + session = create_retry_session(retries=retries, timeout=timeout) + + try: + response = session.get(url, params=params, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"HTTP 요청 실패: {url} - {e}") + return None + except ValueError as e: + logger.error(f"JSON 파싱 실패: {url} - {e}") + return None + finally: + session.close() + + +def post_json( + url: str, + data: Optional[Dict] = None, + json_data: Optional[Dict] = None, + headers: Optional[Dict] = None, + timeout: int = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES +) -> Optional[Dict]: + """ + JSON POST 요청 간편 함수 + + POST 요청을 보내고 JSON 응답을 파싱하여 반환합니다. + + Args: + url: 요청 URL + data: form 데이터 + json_data: JSON 데이터 + headers: 요청 헤더 + timeout: 타임아웃 (초) + retries: 최대 재시도 횟수 + + Returns: + JSON 응답 딕셔너리 또는 None + """ + session = create_retry_session(retries=retries, timeout=timeout) + + try: + response = session.post(url, data=data, json=json_data, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"HTTP POST 요청 실패: {url} - {e}") + return None + except ValueError as e: + logger.error(f"JSON 파싱 실패: {url} - {e}") + return None + finally: + session.close() + + +class APIClient: + """ + API 클라이언트 기본 클래스 + + 특정 API 서비스에 대한 클라이언트를 구현할 때 상속하여 사용합니다. + 공통적인 인증, 베이스 URL, 에러 처리 로직을 제공합니다. + + 사용 예시: + class WeatherAPIClient(APIClient): + def __init__(self, api_key): + super().__init__("https://api.weather.go.kr") + self.api_key = api_key + + def get_forecast(self, city): + return self.get("/forecast", params={"city": city, "key": self.api_key}) + """ + + def __init__( + self, + base_url: str, + default_headers: Optional[Dict] = None, + timeout: int = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES + ): + """ + Args: + base_url: API 기본 URL + default_headers: 기본 요청 헤더 + timeout: 기본 타임아웃 + retries: 기본 재시도 횟수 + """ + self.base_url = base_url.rstrip('/') + self.default_headers = default_headers or {} + self.timeout = timeout + self.session = create_retry_session(retries=retries, timeout=timeout) + + def _build_url(self, endpoint: str) -> str: + """엔드포인트를 포함한 전체 URL 생성""" + if endpoint.startswith('http'): + return endpoint + return f"{self.base_url}/{endpoint.lstrip('/')}" + + def _merge_headers(self, headers: Optional[Dict]) -> Dict: + """기본 헤더와 요청 헤더 병합""" + merged = self.default_headers.copy() + if headers: + merged.update(headers) + return merged + + def get( + self, + endpoint: str, + params: Optional[Dict] = None, + headers: Optional[Dict] = None + ) -> Optional[requests.Response]: + """GET 요청""" + url = self._build_url(endpoint) + merged_headers = self._merge_headers(headers) + + try: + response = self.session.get(url, params=params, headers=merged_headers) + return response + except requests.exceptions.RequestException as e: + logger.error(f"GET 요청 실패: {url} - {e}") + return None + + def post( + self, + endpoint: str, + data: Optional[Dict] = None, + json_data: Optional[Dict] = None, + headers: Optional[Dict] = None + ) -> Optional[requests.Response]: + """POST 요청""" + url = self._build_url(endpoint) + merged_headers = self._merge_headers(headers) + + try: + response = self.session.post( + url, data=data, json=json_data, headers=merged_headers + ) + return response + except requests.exceptions.RequestException as e: + logger.error(f"POST 요청 실패: {url} - {e}") + return None + + def close(self): + """세션 종료""" + self.session.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False diff --git a/core/logging_utils.py b/core/logging_utils.py new file mode 100644 index 0000000..02c193d --- /dev/null +++ b/core/logging_utils.py @@ -0,0 +1,248 @@ +# =================================================================== +# core/logging_utils.py +# FGTools 로깅 유틸리티 모듈 +# =================================================================== +# 일관된 로그 포맷과 설정을 제공하는 로깅 유틸리티입니다. +# 파일 및 콘솔 출력, 로그 레벨 설정을 지원합니다. +# =================================================================== +""" +로깅 유틸리티 모듈 + +일관된 로그 포맷과 핸들러 설정을 제공합니다. +콘솔 출력, 파일 저장, 로그 로테이션을 지원합니다. + +사용 예시: + from core.logging_utils import get_logger, setup_logging + + # 간단한 로거 사용 + logger = get_logger(__name__) + logger.info("메시지") + + # 상세 설정이 필요한 경우 + logger = setup_logging("my_module", level="DEBUG", log_file="app.log") +""" + +import os +import sys +import logging +from typing import Optional +from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler + +from .config import get_config + +# 로그 포맷 상수 +DEFAULT_FORMAT = '[%(asctime)s] %(name)s - %(levelname)s: %(message)s' +DETAILED_FORMAT = '[%(asctime)s] %(name)s (%(filename)s:%(lineno)d) - %(levelname)s: %(message)s' +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + +# 로그 디렉토리 +LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs') + + +def _ensure_log_dir(): + """로그 디렉토리 생성""" + if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR, exist_ok=True) + + +def setup_logging( + name: str, + level: Optional[str] = None, + log_file: Optional[str] = None, + log_format: str = DEFAULT_FORMAT, + max_bytes: int = 10 * 1024 * 1024, # 10MB + backup_count: int = 5, + console_output: bool = True +) -> logging.Logger: + """ + 로거 설정 및 반환 + + 일관된 포맷으로 로거를 설정합니다. 콘솔 출력과 파일 저장을 + 동시에 지원하며, 로그 로테이션을 자동으로 처리합니다. + + Args: + name: 로거 이름 (보통 __name__ 사용) + level: 로그 레벨 (DEBUG, INFO, WARNING, ERROR, CRITICAL) + None이면 환경 설정에서 로드 + log_file: 로그 파일명 (None이면 파일 저장 안 함) + log_format: 로그 메시지 포맷 + max_bytes: 로그 파일 최대 크기 (로테이션 기준) + backup_count: 보관할 백업 파일 수 + console_output: 콘솔 출력 여부 + + Returns: + 설정된 Logger 인스턴스 + """ + # 설정에서 기본 레벨 로드 + if level is None: + try: + level = get_config().log_level + except Exception: + level = 'INFO' + + logger = logging.getLogger(name) + + # 이미 핸들러가 있으면 레벨만 설정하고 반환 + if logger.handlers: + logger.setLevel(getattr(logging, level.upper(), logging.INFO)) + return logger + + # 로그 레벨 설정 + log_level = getattr(logging, level.upper(), logging.INFO) + logger.setLevel(log_level) + + # 포매터 생성 + formatter = logging.Formatter(log_format, datefmt=DATE_FORMAT) + + # 콘솔 핸들러 추가 + if console_output: + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # 파일 핸들러 추가 (요청 시) + if log_file: + _ensure_log_dir() + file_path = os.path.join(LOG_DIR, log_file) + + file_handler = RotatingFileHandler( + file_path, + maxBytes=max_bytes, + backupCount=backup_count, + encoding='utf-8' + ) + file_handler.setLevel(log_level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # 부모 로거로 전파 방지 + logger.propagate = False + + return logger + + +def get_logger(name: str) -> logging.Logger: + """ + 간편 로거 반환 + + 기본 설정으로 로거를 반환합니다. 이미 설정된 로거가 있으면 재사용합니다. + + Args: + name: 로거 이름 (보통 __name__ 사용) + + Returns: + Logger 인스턴스 + """ + return setup_logging(name) + + +def setup_file_logger( + name: str, + log_file: str, + level: str = 'INFO', + rotation: str = 'size', # 'size' 또는 'time' + when: str = 'midnight', # rotation='time'일 때 사용 + interval: int = 1, # rotation='time'일 때 사용 +) -> logging.Logger: + """ + 파일 전용 로거 설정 + + 콘솔 출력 없이 파일에만 로그를 기록합니다. + 크기 기반 또는 시간 기반 로테이션을 선택할 수 있습니다. + + Args: + name: 로거 이름 + log_file: 로그 파일명 + level: 로그 레벨 + rotation: 로테이션 방식 ('size' 또는 'time') + when: 시간 기반 로테이션 주기 (midnight, H, D, W0-W6) + interval: 로테이션 간격 + + Returns: + 설정된 Logger 인스턴스 + """ + _ensure_log_dir() + + logger = logging.getLogger(name) + + if logger.handlers: + return logger + + log_level = getattr(logging, level.upper(), logging.INFO) + logger.setLevel(log_level) + + formatter = logging.Formatter(DETAILED_FORMAT, datefmt=DATE_FORMAT) + file_path = os.path.join(LOG_DIR, log_file) + + if rotation == 'time': + # 시간 기반 로테이션 (예: 매일 자정) + handler = TimedRotatingFileHandler( + file_path, + when=when, + interval=interval, + backupCount=30, + encoding='utf-8' + ) + else: + # 크기 기반 로테이션 + handler = RotatingFileHandler( + file_path, + maxBytes=10 * 1024 * 1024, # 10MB + backupCount=5, + encoding='utf-8' + ) + + handler.setLevel(log_level) + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.propagate = False + + return logger + + +class LogContext: + """ + 로그 컨텍스트 관리자 + + 특정 작업의 시작과 종료를 자동으로 로깅합니다. + 작업 소요 시간도 함께 기록됩니다. + + 사용 예시: + with LogContext(logger, "데이터 처리"): + process_data() + # 출력: + # [시작] 데이터 처리 + # [완료] 데이터 처리 (소요시간: 1.23초) + """ + + def __init__(self, logger: logging.Logger, task_name: str, level: int = logging.INFO): + """ + Args: + logger: Logger 인스턴스 + task_name: 작업 이름 + level: 로그 레벨 + """ + self.logger = logger + self.task_name = task_name + self.level = level + self.start_time = None + + def __enter__(self): + """작업 시작 로깅""" + import time + self.start_time = time.time() + self.logger.log(self.level, f"[시작] {self.task_name}") + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """작업 종료 로깅""" + import time + elapsed = time.time() - self.start_time + + if exc_type is not None: + self.logger.error(f"[실패] {self.task_name} - {exc_type.__name__}: {exc_val}") + else: + self.logger.log(self.level, f"[완료] {self.task_name} (소요시간: {elapsed:.2f}초)") + + return False # 예외 전파 diff --git a/core/message_sender.py b/core/message_sender.py new file mode 100644 index 0000000..fe34aad --- /dev/null +++ b/core/message_sender.py @@ -0,0 +1,331 @@ +# =================================================================== +# core/message_sender.py +# FGTools 다중 플랫폼 메시지 발송 모듈 +# =================================================================== +# Mattermost, Telegram, Synology Chat 등 다양한 플랫폼으로 +# 메시지를 발송하는 통합 클래스를 제공합니다. +# =================================================================== +""" +다중 플랫폼 메시지 발송 모듈 + +Mattermost, Telegram, Synology Chat 등 다양한 메시징 플랫폼으로 +메시지를 발송하는 기능을 제공합니다. + +사용 예시: + from core.message_sender import MessageSender, send_notification + + # 클래스 직접 사용 + sender = MessageSender.from_config() + sender.send("알림 메시지", platforms=['mattermost', 'telegram']) + + # 간편 함수 사용 + send_notification("알림 메시지", platforms=['mattermost']) +""" + +import logging +from typing import List, Optional, Union + +import requests + +from .config import get_config +from .logging_utils import get_logger + +logger = get_logger(__name__) + + +class MessageSender: + """ + 다중 플랫폼 메시지 발송 클래스 + + Mattermost, Telegram, Synology Chat 등 여러 플랫폼으로 + 동시에 메시지를 발송할 수 있습니다. + + Attributes: + mattermost_url: Mattermost 서버 URL + mattermost_token: Mattermost Bot 토큰 + mattermost_channel_id: Mattermost 채널 ID + mattermost_webhook_url: Mattermost 웹훅 URL (선택) + telegram_bot_token: Telegram Bot 토큰 + telegram_chat_id: Telegram 채팅 ID + synology_webhook_url: Synology Chat 웹훅 URL + """ + + def __init__( + self, + mattermost_url: str = "", + mattermost_token: str = "", + mattermost_channel_id: str = "", + mattermost_webhook_url: str = "", + telegram_bot_token: str = "", + telegram_chat_id: str = "", + synology_webhook_url: str = "" + ): + """ + 메시지 발송자 초기화 + + Args: + mattermost_url: Mattermost 서버 URL (예: https://mattermost.example.com) + mattermost_token: Mattermost Bot 인증 토큰 + mattermost_channel_id: 메시지를 보낼 채널 ID + mattermost_webhook_url: 웹훅 URL (웹훅 방식 사용 시) + telegram_bot_token: Telegram Bot API 토큰 + telegram_chat_id: Telegram 채팅방 ID + synology_webhook_url: Synology Chat 웹훅 URL + """ + # Mattermost 설정 + self.mattermost_url = mattermost_url.rstrip('/') if mattermost_url else "" + self.mattermost_token = mattermost_token + self.mattermost_channel_id = mattermost_channel_id + self.mattermost_webhook_url = mattermost_webhook_url + + # Telegram 설정 + self.telegram_bot_token = telegram_bot_token + self.telegram_chat_id = telegram_chat_id + + # Synology Chat 설정 + self.synology_webhook_url = synology_webhook_url + + @classmethod + def from_config(cls) -> 'MessageSender': + """ + 설정 파일에서 메시지 발송자 생성 + + 환경변수에서 모든 메시징 설정을 로드하여 인스턴스를 생성합니다. + + Returns: + 설정이 적용된 MessageSender 인스턴스 + """ + config = get_config() + + return cls( + mattermost_url=config.mattermost.get('url', ''), + mattermost_token=config.mattermost.get('token', ''), + mattermost_channel_id=config.mattermost.get('channel_id', ''), + mattermost_webhook_url=config.mattermost.get('webhook_url', ''), + telegram_bot_token=config.telegram.get('bot_token', ''), + telegram_chat_id=config.telegram.get('chat_id', ''), + synology_webhook_url=config.synology.get('chat_url', ''), + ) + + def send( + self, + message: str, + platforms: Optional[Union[str, List[str]]] = None, + use_webhook: bool = False + ) -> bool: + """ + 지정된 플랫폼으로 메시지 발송 + + 여러 플랫폼에 동시에 메시지를 보낼 수 있습니다. + + Args: + message: 발송할 메시지 내용 + platforms: 발송할 플랫폼 목록 (['mattermost', 'telegram', 'synology']) + None이면 모든 설정된 플랫폼으로 발송 + use_webhook: Mattermost 웹훅 사용 여부 (API 대신) + + Returns: + 모든 발송 성공 시 True, 하나라도 실패 시 False + """ + # 플랫폼이 지정되지 않으면 설정된 모든 플랫폼으로 발송 + if platforms is None: + platforms = self._get_configured_platforms() + + if not platforms: + logger.warning("전송할 플랫폼이 지정되지 않았습니다.") + return False + + # 문자열이면 리스트로 변환 + if isinstance(platforms, str): + platforms = [platforms] + + success = True + for platform in platforms: + platform_lower = platform.lower() + + if platform_lower == "mattermost": + result = self._send_to_mattermost(message, use_webhook) + elif platform_lower == "telegram": + result = self._send_to_telegram(message) + elif platform_lower == "synology": + result = self._send_to_synology(message) + else: + logger.error(f"지원하지 않는 플랫폼: {platform}") + result = False + + if not result: + success = False + + return success + + def _get_configured_platforms(self) -> List[str]: + """설정된 플랫폼 목록 반환""" + platforms = [] + + if self.mattermost_url and (self.mattermost_token or self.mattermost_webhook_url): + platforms.append('mattermost') + if self.telegram_bot_token and self.telegram_chat_id: + platforms.append('telegram') + if self.synology_webhook_url: + platforms.append('synology') + + return platforms + + def _send_to_mattermost(self, message: str, use_webhook: bool = False) -> bool: + """ + Mattermost로 메시지 발송 + + 웹훅 또는 API 방식으로 메시지를 발송합니다. + + Args: + message: 발송할 메시지 + use_webhook: 웹훅 사용 여부 (False면 API 사용) + + Returns: + 발송 성공 여부 + """ + try: + if use_webhook and self.mattermost_webhook_url: + # 웹훅 방식 + response = requests.post( + self.mattermost_webhook_url, + json={"text": message}, + headers={"Content-Type": "application/json"}, + timeout=10 + ) + else: + # API 방식 + if not self._validate_mattermost_config(): + return False + + url = f"{self.mattermost_url}/api/v4/posts" + headers = { + "Authorization": f"Bearer {self.mattermost_token}", + "Content-Type": "application/json" + } + payload = { + "channel_id": self.mattermost_channel_id, + "message": message + } + response = requests.post(url, json=payload, headers=headers, timeout=10) + + if response.status_code in [200, 201]: + logger.info("Mattermost 메시지 전송 완료") + return True + else: + logger.error(f"Mattermost 전송 실패: {response.status_code} - {response.text}") + return False + + except requests.exceptions.Timeout: + logger.error("Mattermost 전송 타임아웃") + return False + except requests.exceptions.RequestException as e: + logger.error(f"Mattermost 전송 예외: {e}") + return False + + def _validate_mattermost_config(self) -> bool: + """Mattermost API 설정 검증""" + if not self.mattermost_url or not self.mattermost_url.startswith(('http://', 'https://')): + logger.error(f"Mattermost URL이 유효하지 않습니다: {self.mattermost_url}") + return False + if not self.mattermost_token: + logger.error("Mattermost 토큰이 설정되지 않았습니다.") + return False + if not self.mattermost_channel_id: + logger.error("Mattermost 채널 ID가 설정되지 않았습니다.") + return False + return True + + def _send_to_telegram(self, message: str) -> bool: + """ + Telegram으로 메시지 발송 + + Args: + message: 발송할 메시지 + + Returns: + 발송 성공 여부 + """ + if not self.telegram_bot_token or not self.telegram_chat_id: + logger.error("Telegram 설정이 완료되지 않았습니다.") + return False + + try: + url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage" + payload = { + "chat_id": self.telegram_chat_id, + "text": message, + "parse_mode": "Markdown" + } + response = requests.post(url, data=payload, timeout=10) + + if response.status_code == 200: + logger.info("Telegram 메시지 전송 완료") + return True + else: + logger.error(f"Telegram 전송 실패: {response.status_code} - {response.text}") + return False + + except requests.exceptions.RequestException as e: + logger.error(f"Telegram 전송 예외: {e}") + return False + + def _send_to_synology(self, message: str) -> bool: + """ + Synology Chat으로 메시지 발송 + + Args: + message: 발송할 메시지 + + Returns: + 발송 성공 여부 + """ + if not self.synology_webhook_url: + logger.error("Synology Chat 웹훅 URL이 설정되지 않았습니다.") + return False + + try: + payload = {"text": message} + headers = {"Content-Type": "application/json"} + response = requests.post( + self.synology_webhook_url, + json=payload, + headers=headers, + timeout=10 + ) + + if response.status_code == 200: + logger.info("Synology Chat 메시지 전송 완료") + return True + else: + logger.error(f"Synology Chat 전송 실패: {response.status_code} - {response.text}") + return False + + except requests.exceptions.RequestException as e: + logger.error(f"Synology Chat 전송 예외: {e}") + return False + + +def send_notification( + message: str, + platforms: Optional[Union[str, List[str]]] = None, + use_webhook: bool = False +) -> bool: + """ + 알림 메시지 발송 간편 함수 + + 설정 파일에서 자동으로 설정을 로드하여 메시지를 발송합니다. + + Args: + message: 발송할 메시지 + platforms: 발송할 플랫폼 목록 (None이면 모든 설정된 플랫폼) + use_webhook: Mattermost 웹훅 사용 여부 + + Returns: + 발송 성공 여부 + + 사용 예시: + send_notification("서버 점검 알림", platforms=['mattermost']) + """ + sender = MessageSender.from_config() + return sender.send(message, platforms=platforms, use_webhook=use_webhook) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4b100bc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,62 @@ +# =================================================================== +# requirements.txt +# FGTools 통합 프로젝트 의존성 +# =================================================================== +# 모든 서비스에서 사용하는 패키지들의 통합 목록입니다. +# pip install -r requirements.txt +# =================================================================== + +# ===== Web Framework ===== +flask>=3.0.0 + +# ===== Database ===== +sqlalchemy>=2.0.23 +pymysql>=1.1.0 + +# ===== HTTP & API ===== +requests>=2.31.0 +urllib3>=1.26.0 + +# ===== Data Processing ===== +pandas>=2.1.3 +openpyxl>=3.1.2 +xlrd>=2.0.1 + +# ===== Configuration ===== +pyyaml>=6.0.1 +python-dotenv>=1.0.0 + +# ===== Date & Time ===== +pytz>=2023.3 +python-dateutil>=2.8.2 + +# ===== Google Analytics ===== +google-analytics-data>=0.18.5 + +# ===== Forecasting (Optional) ===== +# 방문객 예측 기능 사용 시 설치 +# prophet>=1.1.5 +# statsmodels>=0.14.0 +# scikit-learn>=1.3.2 + +# ===== Visualization (Optional) ===== +# 데이터 시각화 사용 시 설치 +# matplotlib>=3.8.2 + +# ===== Web Automation (Optional) ===== +# 날씨 캡처 기능 사용 시 설치 +# selenium>=4.0.0 +# pillow>=10.0.0 + +# ===== FTP (Optional) ===== +# 파일 업로드 기능 사용 시 설치 +# ftputil>=5.0.0 + +# ===== GUI (Optional) ===== +# GUI 도구 사용 시 설치 +# customtkinter>=5.2.0 +# tkcalendar>=1.6.1 + +# ===== Utilities ===== +tabulate>=0.9.0 +watchdog>=3.0.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..280e82b --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,17 @@ +# =================================================================== +# services/__init__.py +# FGTools 서비스 패키지 초기화 +# =================================================================== +# 도메인별 서비스 모듈들을 제공합니다: +# - weather: 기상 데이터 서비스 +# - pos: POS 데이터 서비스 +# - analytics: 분석 서비스 (GA4, 대기질, 예측) +# - notification: 알림 서비스 (Notion, Mattermost) +# =================================================================== + +__all__ = [ + 'weather', + 'pos', + 'analytics', + 'notification', +] diff --git a/services/analytics/__init__.py b/services/analytics/__init__.py new file mode 100644 index 0000000..d33b1dc --- /dev/null +++ b/services/analytics/__init__.py @@ -0,0 +1,18 @@ +# =================================================================== +# services/analytics/__init__.py +# 분석 서비스 패키지 초기화 +# =================================================================== +# GA4, 대기질, 방문객 예측 등 분석 관련 서비스를 제공합니다. +# =================================================================== + +from .ga4 import GA4Client, GA4DataCollector +from .air_quality import AirQualityCollector, get_air_quality +from .visitor_forecast import VisitorForecaster + +__all__ = [ + 'GA4Client', + 'GA4DataCollector', + 'AirQualityCollector', + 'get_air_quality', + 'VisitorForecaster', +] diff --git a/services/analytics/air_quality.py b/services/analytics/air_quality.py new file mode 100644 index 0000000..26b7fd2 --- /dev/null +++ b/services/analytics/air_quality.py @@ -0,0 +1,426 @@ +# =================================================================== +# services/analytics/air_quality.py +# 대기질 데이터 수집 서비스 모듈 +# =================================================================== +# 한국환경공단 API를 통해 대기질(미세먼지) 데이터를 수집합니다. +# 측정소별 PM2.5, PM10, SO2, CO, NO2, O3 데이터를 저장합니다. +# =================================================================== +""" +대기질 데이터 수집 서비스 모듈 + +한국환경공단 공공데이터 API를 통해 대기질 데이터를 수집합니다. +측정소별 일평균 대기오염물질 농도를 조회할 수 있습니다. + +사용 예시: + from services.analytics.air_quality import AirQualityCollector, get_air_quality + + # 간단한 데이터 조회 + data = get_air_quality(service_key, '운정', '20240101', '20240131') + + # 자동 데이터 수집 및 저장 + collector = AirQualityCollector(config, engine, table) + collector.run() +""" + +import os +import json +import traceback +from datetime import datetime, timedelta, date +from typing import Dict, List, Optional, Any + +from sqlalchemy import select, func, and_, Table +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.exc import IntegrityError +from sqlalchemy.engine import Engine, Connection + +from core.logging_utils import get_logger +from core.http_client import create_retry_session +from core.config import get_config + +logger = get_logger(__name__) + +# API URL +AIR_QUALITY_API_URL = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg" + + +def get_air_quality( + service_key: str, + station_name: str, + start_date: str, + end_date: str, + num_of_rows: int = 100, + page_no: int = 1 +) -> List[Dict]: + """ + 대기질 데이터 조회 + + 한국환경공단 API를 호출하여 측정소별 대기질 데이터를 조회합니다. + + Args: + service_key: 공공데이터포털 API 키 + station_name: 측정소명 (예: '운정', '서울') + start_date: 시작 날짜 (YYYYMMDD) + end_date: 종료 날짜 (YYYYMMDD) + num_of_rows: 페이지당 결과 수 + page_no: 페이지 번호 + + Returns: + 대기질 데이터 리스트 + + 데이터 항목: + - msurDt: 측정일 (YYYY-MM-DD) + - pm25Value: 초미세먼지 농도 (㎍/㎥) + - pm10Value: 미세먼지 농도 (㎍/㎥) + - so2Value: 아황산가스 농도 (ppm) + - coValue: 일산화탄소 농도 (ppm) + - no2Value: 이산화질소 농도 (ppm) + - o3Value: 오존 농도 (ppm) + """ + params = { + 'serviceKey': service_key, + 'returnType': 'json', + 'numOfRows': str(num_of_rows), + 'pageNo': str(page_no), + 'inqBginDt': start_date, + 'inqEndDt': end_date, + 'msrstnName': station_name, + } + + session = create_retry_session(retries=3) + + try: + response = session.get(AIR_QUALITY_API_URL, params=params, timeout=20) + response.raise_for_status() + data = response.json() + + items = data.get('response', {}).get('body', {}).get('items', []) + logger.debug(f"대기질 데이터 조회: {station_name}, {len(items)}건") + return items if items else [] + + except Exception as e: + logger.error(f"대기질 API 요청 실패: {e}") + traceback.print_exc() + return [] + finally: + session.close() + + +class AirQualityCollector: + """ + 대기질 데이터 자동 수집기 + + 설정에 따라 대기질 데이터를 자동으로 수집하고 DB에 저장합니다. + + Attributes: + api_key: API 서비스 키 + station_list: 측정소 목록 + engine: SQLAlchemy 엔진 + table: 대상 테이블 + start_date: 수집 시작일 + force_update: 강제 업데이트 여부 + debug: 디버그 모드 + """ + + # 캐시 파일 경로 + CACHE_FILE = 'cache/air_num_rows.json' + + def __init__( + self, + engine: Engine, + table: Table, + api_key: Optional[str] = None, + station_list: Optional[List[str]] = None, + start_date: Optional[str] = None, + force_update: bool = False, + debug: bool = False + ): + """ + Args: + engine: SQLAlchemy 엔진 + table: 대상 테이블 + api_key: API 키 (None이면 설정에서 로드) + station_list: 측정소 목록 (None이면 설정에서 로드) + start_date: 수집 시작일 (YYYYMMDD) + force_update: 기존 데이터 덮어쓰기 여부 + debug: 디버그 모드 + """ + config = get_config() + + self.api_key = api_key or config.data_api.get('service_key', '') + self.station_list = station_list or config.data_api.get('air_stations', ['운정']) + + if start_date: + self.start_date = datetime.strptime(start_date, '%Y%m%d').date() + else: + self.start_date = datetime.strptime( + config.data_api.get('start_date', '20170101'), '%Y%m%d' + ).date() + + self.engine = engine + self.table = table + self.force_update = force_update + self.debug = debug + + self.yesterday = (datetime.now() - timedelta(days=1)).date() + self.session = create_retry_session(retries=3) + + # 캐시 로드 + self._num_rows_cache = self._load_cache() + + def _load_cache(self) -> Dict: + """캐시 파일 로드""" + try: + cache_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + self.CACHE_FILE + ) + if os.path.exists(cache_path): + with open(cache_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + pass + return {} + + def _save_cache(self): + """캐시 파일 저장""" + try: + cache_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + self.CACHE_FILE + ) + os.makedirs(os.path.dirname(cache_path), exist_ok=True) + with open(cache_path, 'w', encoding='utf-8') as f: + json.dump(self._num_rows_cache, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.warning(f"캐시 저장 실패: {e}") + + def get_latest_date(self, conn: Connection, station: str) -> Optional[date]: + """ + 특정 측정소의 가장 최근 저장 날짜 조회 + + Args: + conn: DB 연결 + station: 측정소명 + + Returns: + 최근 날짜 또는 None + """ + try: + stmt = select(func.max(self.table.c.date)).where( + self.table.c.station == station + ) + result = conn.execute(stmt).scalar() + return result + except Exception as e: + logger.error(f"최근 날짜 조회 실패: {e}") + return None + + def parse_item_to_record(self, item: Dict, station: str) -> Optional[Dict]: + """ + API 응답 아이템을 DB 레코드로 변환 + + Args: + item: API 응답 아이템 + station: 측정소명 + + Returns: + DB 레코드 딕셔너리 또는 None + """ + try: + item_date = datetime.strptime(item['msurDt'], '%Y-%m-%d').date() + except Exception as e: + logger.warning(f"날짜 파싱 오류: {item.get('msurDt')} - {e}") + return None + + def safe_float(val): + """안전한 float 변환""" + try: + return float(val) if val else None + except (ValueError, TypeError): + return None + + return { + 'date': item_date, + 'station': station, + 'pm25': safe_float(item.get('pm25Value')), + 'pm10': safe_float(item.get('pm10Value')), + 'so2': safe_float(item.get('so2Value')), + 'co': safe_float(item.get('coValue')), + 'no2': safe_float(item.get('no2Value')), + 'o3': safe_float(item.get('o3Value')), + } + + def save_items_to_db( + self, + items: List[Dict], + conn: Connection, + station: str + ) -> int: + """ + 데이터 항목들을 DB에 저장 + + Args: + items: 저장할 데이터 리스트 + conn: DB 연결 + station: 측정소명 + + Returns: + 저장된 레코드 수 + """ + saved_count = 0 + + for item in items: + data = self.parse_item_to_record(item, station) + if not data: + continue + + item_date = data['date'] + + if self.debug: + logger.debug(f"[DEBUG] {item_date} [{station}] 저장 시도: {data}") + continue + + try: + if self.force_update: + # UPSERT + stmt = mysql_insert(self.table).values(**data) + stmt = stmt.on_duplicate_key_update(**data) + conn.execute(stmt) + logger.info(f"{item_date} [{station}] 저장/업데이트 완료") + else: + # 중복 확인 후 삽입 + sel = select(self.table.c.date).where( + and_( + self.table.c.date == item_date, + self.table.c.station == station + ) + ) + if conn.execute(sel).fetchone(): + logger.debug(f"{item_date} [{station}] 이미 존재, 생략") + continue + + conn.execute(self.table.insert().values(**data)) + logger.info(f"{item_date} [{station}] 저장 완료") + + saved_count += 1 + + except IntegrityError as e: + logger.error(f"중복 오류: {e}") + except Exception as e: + logger.error(f"저장 실패: {e}") + traceback.print_exc() + + return saved_count + + def find_optimal_num_rows(self, station_name: str, date_str: str) -> int: + """ + 최적의 numOfRows 파라미터 값 탐색 + + API 서버 상태에 따라 최대 허용 rows 수가 다를 수 있어 + 적절한 값을 탐색합니다. + + Args: + station_name: 측정소명 + date_str: 날짜 (YYYYMMDD) + + Returns: + 최적의 numOfRows 값 (100~1000) + """ + # 캐시 확인 + cache_key = f"{station_name}:{date_str}" + if cache_key in self._num_rows_cache: + cached_val = int(self._num_rows_cache[cache_key]) + logger.debug(f"캐시된 numOfRows 사용: {cached_val}") + return cached_val + + # 점진적으로 감소하며 테스트 + max_rows = 1000 + min_rows = 100 + + while max_rows >= min_rows: + try: + params = { + 'serviceKey': self.api_key, + 'returnType': 'json', + 'numOfRows': str(max_rows), + 'pageNo': '1', + 'inqBginDt': date_str, + 'inqEndDt': date_str, + 'msrstnName': station_name, + } + response = self.session.get(AIR_QUALITY_API_URL, params=params, timeout=20) + response.raise_for_status() + response.json() # JSON 파싱 테스트 + + # 성공 - 캐시에 저장 + self._num_rows_cache[cache_key] = max_rows + self._save_cache() + + logger.debug(f"numOfRows 최대값: {max_rows}") + return max_rows + + except Exception as e: + logger.warning(f"numOfRows={max_rows} 실패: {e}, 재시도...") + max_rows -= 100 + + logger.warning("기본값 100 사용") + return 100 + + def run(self) -> int: + """ + 데이터 수집 및 저장 실행 + + 모든 측정소에 대해 데이터를 수집하고 DB에 저장합니다. + + Returns: + 총 저장된 레코드 수 + """ + total_saved = 0 + + with self.engine.begin() as conn: + for station_name in self.station_list: + logger.info(f"측정소 처리 시작: {station_name}") + + # 시작일 결정 + latest_date = self.get_latest_date(conn, station_name) + if latest_date: + start_date = latest_date + timedelta(days=1) + else: + start_date = self.start_date + + if start_date > self.yesterday: + logger.info(f"{station_name}: 최신 데이터 존재 ({latest_date})") + continue + + # 최적 numOfRows 탐색 + optimal_rows = self.find_optimal_num_rows( + station_name, + start_date.strftime('%Y%m%d') + ) + + # 청크 단위로 데이터 수집 + current_start = start_date + while current_start <= self.yesterday: + current_end = min( + current_start + timedelta(days=optimal_rows - 1), + self.yesterday + ) + + logger.info(f"{station_name}: {current_start} ~ {current_end} 수집") + + items = get_air_quality( + self.api_key, + station_name, + current_start.strftime('%Y%m%d'), + current_end.strftime('%Y%m%d'), + num_of_rows=optimal_rows + ) + + if items: + saved = self.save_items_to_db(items, conn, station_name) + total_saved += saved + + current_start = current_end + timedelta(days=1) + + logger.info(f"대기질 데이터 수집 완료: 총 {total_saved}건 저장") + return total_saved diff --git a/services/analytics/ga4.py b/services/analytics/ga4.py new file mode 100644 index 0000000..b2a5420 --- /dev/null +++ b/services/analytics/ga4.py @@ -0,0 +1,401 @@ +# =================================================================== +# services/analytics/ga4.py +# Google Analytics 4 데이터 수집 서비스 모듈 +# =================================================================== +# GA4 API를 통해 웹사이트 방문자 데이터를 수집하고 DB에 저장합니다. +# 병렬 처리를 통해 대량 데이터 수집 성능을 최적화합니다. +# =================================================================== +""" +Google Analytics 4 데이터 수집 서비스 모듈 + +GA4 Data API를 사용하여 웹사이트 분석 데이터를 수집합니다. +일별 세션, 사용자, 이벤트 등 다양한 메트릭을 조회할 수 있습니다. + +사용 예시: + from services.analytics.ga4 import GA4Client, GA4DataCollector + + # 간단한 데이터 조회 + client = GA4Client(property_id, service_account_file) + data = client.get_daily_sessions('2024-01-01', '2024-01-31') + + # 자동 데이터 수집 및 저장 + collector = GA4DataCollector(config) + collector.collect_and_save() +""" + +import os +import traceback +from datetime import datetime, timedelta, date +from typing import Dict, List, Optional, Tuple, Any +from concurrent.futures import ThreadPoolExecutor, as_completed + +from dateutil.parser import parse as parse_date +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.exc import IntegrityError +from sqlalchemy import select, func, Table + +from core.logging_utils import get_logger +from core.config import get_config + +logger = get_logger(__name__) + +# GA4 라이브러리 임포트 (설치 필요) +try: + from google.analytics.data import BetaAnalyticsDataClient + from google.analytics.data_v1beta.types import ( + DateRange, Dimension, Metric, RunReportRequest + ) + GA4_AVAILABLE = True +except ImportError: + GA4_AVAILABLE = False + logger.warning("google-analytics-data 패키지가 설치되지 않았습니다.") + + +class GA4Client: + """ + Google Analytics 4 API 클라이언트 + + GA4 Data API를 통해 리포트 데이터를 조회합니다. + + Attributes: + property_id: GA4 속성 ID + client: BetaAnalyticsDataClient 인스턴스 + max_rows: API 요청당 최대 행 수 + """ + + def __init__( + self, + property_id: int, + service_account_file: Optional[str] = None, + max_rows: int = 10000 + ): + """ + Args: + property_id: GA4 속성 ID + service_account_file: 서비스 계정 JSON 파일 경로 + max_rows: 요청당 최대 행 수 + + Raises: + ImportError: google-analytics-data 패키지 미설치 시 + Exception: 인증 실패 시 + """ + if not GA4_AVAILABLE: + raise ImportError( + "GA4 기능을 사용하려면 google-analytics-data 패키지를 설치하세요: " + "pip install google-analytics-data" + ) + + self.property_id = property_id + self.max_rows = max_rows + + # 서비스 계정 인증 설정 + if service_account_file: + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = service_account_file + logger.info(f"GA4 클라이언트 초기화 - 인증파일: {service_account_file}") + + try: + self.client = BetaAnalyticsDataClient() + logger.info("GA4 클라이언트 초기화 완료") + except Exception as e: + logger.error(f"GA4 클라이언트 초기화 실패: {e}") + traceback.print_exc() + raise + + def run_report( + self, + start_date: str, + end_date: str, + dimensions: List[str], + metrics: List[str], + limit: Optional[int] = None + ) -> Optional[Any]: + """ + GA4 리포트 실행 + + Args: + start_date: 시작 날짜 (YYYY-MM-DD) + end_date: 종료 날짜 (YYYY-MM-DD) + dimensions: 차원 목록 (예: ['date', 'city']) + metrics: 메트릭 목록 (예: ['sessions', 'activeUsers']) + limit: 결과 제한 (None이면 max_rows 사용) + + Returns: + RunReportResponse 또는 None (실패 시) + """ + if limit is None: + limit = self.max_rows + + logger.debug(f"GA4 리포트 요청: {start_date} ~ {end_date}, dims={dimensions}, metrics={metrics}") + + try: + request = RunReportRequest( + property=f"properties/{self.property_id}", + dimensions=[Dimension(name=d) for d in dimensions], + metrics=[Metric(name=m) for m in metrics], + date_ranges=[DateRange(start_date=start_date, end_date=end_date)], + limit=limit, + ) + response = self.client.run_report(request) + logger.info(f"GA4 리포트 응답: {len(response.rows)}건") + return response + + except Exception as e: + logger.error(f"GA4 리포트 요청 실패: {e}") + traceback.print_exc() + return None + + def get_daily_sessions( + self, + start_date: str, + end_date: str + ) -> List[Dict]: + """ + 일별 세션 데이터 조회 + + Args: + start_date: 시작 날짜 (YYYY-MM-DD) + end_date: 종료 날짜 (YYYY-MM-DD) + + Returns: + 일별 세션 데이터 리스트 + [{'date': date, 'sessions': int, 'activeUsers': int}, ...] + """ + response = self.run_report( + start_date=start_date, + end_date=end_date, + dimensions=['date'], + metrics=['sessions', 'activeUsers'] + ) + + if response is None: + return [] + + result = [] + for row in response.rows: + date_str = row.dimension_values[0].value + result.append({ + 'date': datetime.strptime(date_str, "%Y%m%d").date(), + 'sessions': int(row.metric_values[0].value), + 'activeUsers': int(row.metric_values[1].value) + }) + + return result + + def detect_max_rows(self) -> int: + """ + API에서 지원하는 최대 행 수 감지 + + Returns: + 최대 행 수 (감지 실패 시 기본값 10000) + """ + try: + request = RunReportRequest( + property=f"properties/{self.property_id}", + dimensions=[Dimension(name="date")], + metrics=[Metric(name="sessions")], + date_ranges=[DateRange(start_date="2024-01-01", end_date="2024-12-31")], + limit=100000 + ) + response = self.client.run_report(request) + n_rows = len(response.rows) + logger.info(f"최대 행 수 감지: {n_rows}") + return n_rows + except Exception as e: + logger.warning(f"최대 행 수 감지 실패: {e}") + return 10000 + + +class GA4DataCollector: + """ + GA4 데이터 자동 수집기 + + 설정에 따라 GA4 데이터를 자동으로 수집하고 DB에 저장합니다. + + Attributes: + client: GA4Client 인스턴스 + engine: SQLAlchemy 엔진 + table: 대상 테이블 + force_update: 강제 업데이트 여부 + debug: 디버그 모드 + """ + + def __init__( + self, + engine, + table: Table, + property_id: Optional[int] = None, + service_account_file: Optional[str] = None, + force_update: bool = False, + debug: bool = False + ): + """ + Args: + engine: SQLAlchemy 엔진 + table: 대상 테이블 + property_id: GA4 속성 ID (None이면 설정에서 로드) + service_account_file: 서비스 계정 파일 (None이면 설정에서 로드) + force_update: 기존 데이터 덮어쓰기 여부 + debug: 디버그 모드 + """ + config = get_config() + + if property_id is None: + property_id = config.ga4.get('property_id') + if service_account_file is None: + service_account_file = config.ga4.get('service_account_file') + + self.client = GA4Client(property_id, service_account_file) + self.engine = engine + self.table = table + self.force_update = force_update + self.debug = debug + + # 설정에서 날짜 범위 로드 + self.config_start_date = datetime.strptime( + config.ga4.get('start_date', '20170101'), '%Y%m%d' + ).date() + self.config_end_date = datetime.strptime( + config.ga4.get('end_date', '20991231'), '%Y%m%d' + ).date() + + def get_latest_date_from_db(self) -> Optional[date]: + """DB에서 가장 최근 저장된 날짜 조회""" + with self.engine.connect() as conn: + stmt = select(func.max(self.table.c.date)) + result = conn.execute(stmt).scalar() + logger.info(f"DB 기준 마지막 저장 날짜: {result}") + return result + + def determine_date_range(self) -> Tuple[date, date]: + """ + 수집할 날짜 범위 결정 + + Returns: + (시작일, 종료일) 튜플 + """ + yesterday = datetime.now().date() - timedelta(days=1) + actual_end = min(yesterday, self.config_end_date) + + if self.force_update: + actual_start = self.config_start_date + else: + latest_db_date = self.get_latest_date_from_db() + if latest_db_date is not None: + actual_start = latest_db_date + timedelta(days=1) + else: + actual_start = self.config_start_date + + return actual_start, actual_end + + def save_response_to_db( + self, + response, + dimension_names: List[str], + metric_names: List[str] + ) -> int: + """ + GA4 응답 데이터를 DB에 저장 + + Args: + response: GA4 RunReportResponse + dimension_names: 차원 이름 목록 + metric_names: 메트릭 이름 목록 + + Returns: + 저장된 레코드 수 + """ + if response is None: + return 0 + + saved_count = 0 + + with self.engine.begin() as conn: + for row in response.rows: + data = {} + + # 차원 처리 + for i, dim_name in enumerate(dimension_names): + try: + val = row.dimension_values[i].value + if dim_name == "date": + if len(val) == 8: + val = datetime.strptime(val, "%Y%m%d").date() + else: + val = parse_date(val).date() + data[dim_name] = val + except (IndexError, ValueError) as e: + logger.warning(f"차원 처리 오류 ({dim_name}): {e}") + + # 메트릭 처리 + for i, met_name in enumerate(metric_names): + try: + data[met_name] = int(row.metric_values[i].value) + except (IndexError, ValueError): + data[met_name] = None + + # DB 저장 + if self.debug: + logger.debug(f"[DEBUG] 저장할 데이터: {data}") + continue + + try: + stmt = mysql_insert(self.table).values(**data) + stmt = stmt.on_duplicate_key_update(**data) + conn.execute(stmt) + saved_count += 1 + except IntegrityError as e: + logger.error(f"중복 오류: {e}") + except Exception as e: + logger.error(f"저장 실패: {e}") + traceback.print_exc() + + return saved_count + + def collect_and_save( + self, + dimensions: List[str] = ['date'], + metrics: List[str] = ['sessions', 'activeUsers'], + chunk_days: int = 30 + ) -> int: + """ + 데이터 수집 및 저장 실행 + + Args: + dimensions: 수집할 차원 목록 + metrics: 수집할 메트릭 목록 + chunk_days: 청크 크기 (일) + + Returns: + 총 저장된 레코드 수 + """ + start_date, end_date = self.determine_date_range() + + if start_date > end_date: + logger.info("최신 데이터가 이미 존재합니다.") + return 0 + + logger.info(f"GA4 데이터 수집 시작: {start_date} ~ {end_date}") + + total_saved = 0 + current_start = start_date + + while current_start <= end_date: + current_end = min(current_start + timedelta(days=chunk_days - 1), end_date) + + logger.info(f"청크 처리: {current_start} ~ {current_end}") + + response = self.client.run_report( + start_date=current_start.strftime("%Y-%m-%d"), + end_date=current_end.strftime("%Y-%m-%d"), + dimensions=dimensions, + metrics=metrics + ) + + if response: + saved = self.save_response_to_db(response, dimensions, metrics) + total_saved += saved + + current_start = current_end + timedelta(days=1) + + logger.info(f"GA4 데이터 수집 완료: 총 {total_saved}건 저장") + return total_saved diff --git a/services/analytics/visitor_forecast.py b/services/analytics/visitor_forecast.py new file mode 100644 index 0000000..188c4b3 --- /dev/null +++ b/services/analytics/visitor_forecast.py @@ -0,0 +1,300 @@ +# =================================================================== +# services/analytics/visitor_forecast.py +# 방문객 예측 서비스 모듈 +# =================================================================== +# 날씨, 휴일, 과거 데이터를 기반으로 방문객 수를 예측합니다. +# 간단한 가중치 기반 모델과 Prophet 시계열 모델을 지원합니다. +# =================================================================== +""" +방문객 예측 서비스 모듈 + +날씨 조건, 휴일 여부, 과거 방문 패턴을 분석하여 +미래 방문객 수를 예측합니다. + +사용 예시: + from services.analytics.visitor_forecast import VisitorForecaster + + forecaster = VisitorForecaster(config) + predictions = forecaster.predict_weekly() +""" + +from datetime import datetime, timedelta, date +from typing import Dict, List, Optional, Tuple, Any + +from core.logging_utils import get_logger +from core.config import get_config + +logger = get_logger(__name__) + + +class VisitorForecaster: + """ + 방문객 예측 클래스 + + 다양한 요소를 고려하여 방문객 수를 예측합니다. + + Attributes: + weights: 예측 가중치 설정 + visitor_multiplier: 최종 예측값 조정 계수 + """ + + def __init__( + self, + weights: Optional[Dict] = None, + visitor_multiplier: float = 0.5 + ): + """ + Args: + weights: 예측 가중치 (None이면 설정에서 로드) + visitor_multiplier: 예측값 조정 계수 + """ + if weights is None: + config = get_config() + forecast_config = config.forecast_weight + + self.weights = { + 'min_temp': forecast_config.get('min_temp', 1.0), + 'max_temp': forecast_config.get('max_temp', 1.0), + 'precipitation': forecast_config.get('precipitation', 10.0), + 'humidity': forecast_config.get('humidity', 1.0), + 'pm25': forecast_config.get('pm25', 1.0), + 'holiday': forecast_config.get('holiday', 20), + } + self.visitor_multiplier = forecast_config.get('visitor_multiplier', 0.5) + else: + self.weights = weights + self.visitor_multiplier = visitor_multiplier + + def calculate_weather_impact( + self, + min_temp: float, + max_temp: float, + precipitation: float, + humidity: float, + pm25: Optional[float] = None + ) -> float: + """ + 날씨 조건에 따른 방문객 영향도 계산 + + 각 날씨 요소가 방문객 수에 미치는 영향을 계산합니다. + 높은 값일수록 방문객 수 감소를 의미합니다. + + Args: + min_temp: 최저 기온 (℃) + max_temp: 최고 기온 (℃) + precipitation: 강수량 (mm) + humidity: 습도 (%) + pm25: 초미세먼지 농도 (㎍/㎥) + + Returns: + 날씨 영향도 점수 (높을수록 부정적) + """ + impact = 0.0 + + # 기온 영향 (너무 낮거나 높으면 부정적) + # 최적 온도: 15~25℃ + if min_temp < 0: + impact += abs(min_temp) * self.weights['min_temp'] + elif min_temp < 10: + impact += (10 - min_temp) * self.weights['min_temp'] * 0.3 + + if max_temp > 35: + impact += (max_temp - 35) * self.weights['max_temp'] + elif max_temp > 30: + impact += (max_temp - 30) * self.weights['max_temp'] * 0.5 + + # 강수량 영향 (비가 오면 크게 부정적) + if precipitation > 0: + impact += precipitation * self.weights['precipitation'] + + # 습도 영향 + if humidity > 80: + impact += (humidity - 80) * self.weights['humidity'] * 0.1 + + # 미세먼지 영향 + if pm25 is not None: + if pm25 > 75: # 나쁨 기준 + impact += (pm25 - 75) * self.weights['pm25'] * 0.5 + elif pm25 > 35: # 보통 기준 + impact += (pm25 - 35) * self.weights['pm25'] * 0.2 + + return impact + + def calculate_holiday_impact(self, is_holiday: bool, is_weekend: bool) -> float: + """ + 휴일/주말에 따른 방문객 영향도 계산 + + Args: + is_holiday: 공휴일 여부 + is_weekend: 주말 여부 + + Returns: + 휴일 영향도 (양수: 방문객 증가, 음수: 감소) + """ + if is_holiday: + return self.weights['holiday'] + elif is_weekend: + return self.weights['holiday'] * 0.7 + else: + return 0.0 + + def predict_visitors( + self, + base_visitors: float, + weather_data: Dict, + is_holiday: bool = False, + is_weekend: bool = False + ) -> float: + """ + 방문객 수 예측 + + 기준 방문객 수에 날씨와 휴일 영향을 적용하여 예측합니다. + + Args: + base_visitors: 기준 방문객 수 (과거 평균) + weather_data: 날씨 데이터 딕셔너리 + - min_temp: 최저 기온 + - max_temp: 최고 기온 + - precipitation: 강수량 (또는 sumRn) + - humidity: 습도 (또는 avgRhm) + - pm25: 미세먼지 (선택) + is_holiday: 공휴일 여부 + is_weekend: 주말 여부 + + Returns: + 예측 방문객 수 + """ + # 날씨 데이터 추출 + min_temp = weather_data.get('min_temp', weather_data.get('minTa', 15)) + max_temp = weather_data.get('max_temp', weather_data.get('maxTa', 25)) + precipitation = weather_data.get('precipitation', weather_data.get('sumRn', 0)) + humidity = weather_data.get('humidity', weather_data.get('avgRhm', 50)) + pm25 = weather_data.get('pm25') + + # 영향도 계산 + weather_impact = self.calculate_weather_impact( + min_temp, max_temp, precipitation, humidity, pm25 + ) + holiday_impact = self.calculate_holiday_impact(is_holiday, is_weekend) + + # 예측값 계산 + # 날씨 영향은 감소 효과, 휴일 영향은 증가 효과 + adjustment = holiday_impact - weather_impact + + # 조정 계수 적용 + predicted = base_visitors * (1 + adjustment / 100 * self.visitor_multiplier) + + # 최소값 보장 + return max(0, predicted) + + def predict_weekly( + self, + base_visitors: float, + weekly_weather: Dict[str, Dict], + holidays: Optional[List[date]] = None + ) -> Dict[str, float]: + """ + 주간 방문객 예측 + + Args: + base_visitors: 기준 방문객 수 + weekly_weather: 일별 날씨 데이터 {YYYYMMDD: weather_data} + holidays: 휴일 목록 + + Returns: + 일별 예측 방문객 {YYYYMMDD: visitors} + """ + if holidays is None: + holidays = [] + + predictions = {} + + for date_str, weather in weekly_weather.items(): + try: + dt = datetime.strptime(date_str, '%Y%m%d').date() + is_holiday = dt in holidays + is_weekend = dt.weekday() >= 5 + + predicted = self.predict_visitors( + base_visitors, + weather, + is_holiday, + is_weekend + ) + + predictions[date_str] = round(predicted) + + logger.debug( + f"{date_str}: 기준={base_visitors}, " + f"휴일={is_holiday}, 주말={is_weekend}, " + f"예측={predicted:.0f}" + ) + + except Exception as e: + logger.warning(f"예측 실패 ({date_str}): {e}") + predictions[date_str] = base_visitors + + return predictions + + def analyze_prediction_factors( + self, + weather_data: Dict, + is_holiday: bool = False, + is_weekend: bool = False + ) -> Dict[str, Any]: + """ + 예측 요인 분석 + + 각 요인이 예측에 미치는 영향을 분석합니다. + + Args: + weather_data: 날씨 데이터 + is_holiday: 공휴일 여부 + is_weekend: 주말 여부 + + Returns: + 요인별 영향 분석 결과 + """ + min_temp = weather_data.get('min_temp', weather_data.get('minTa', 15)) + max_temp = weather_data.get('max_temp', weather_data.get('maxTa', 25)) + precipitation = weather_data.get('precipitation', weather_data.get('sumRn', 0)) + humidity = weather_data.get('humidity', weather_data.get('avgRhm', 50)) + pm25 = weather_data.get('pm25') + + analysis = { + 'weather': { + 'min_temp': { + 'value': min_temp, + 'impact': abs(min_temp) * self.weights['min_temp'] if min_temp < 0 else 0 + }, + 'max_temp': { + 'value': max_temp, + 'impact': (max_temp - 35) * self.weights['max_temp'] if max_temp > 35 else 0 + }, + 'precipitation': { + 'value': precipitation, + 'impact': precipitation * self.weights['precipitation'] if precipitation > 0 else 0 + }, + 'humidity': { + 'value': humidity, + 'impact': (humidity - 80) * self.weights['humidity'] * 0.1 if humidity > 80 else 0 + } + }, + 'holiday': { + 'is_holiday': is_holiday, + 'is_weekend': is_weekend, + 'impact': self.calculate_holiday_impact(is_holiday, is_weekend) + }, + 'total_weather_impact': self.calculate_weather_impact( + min_temp, max_temp, precipitation, humidity, pm25 + ), + 'total_holiday_impact': self.calculate_holiday_impact(is_holiday, is_weekend) + } + + if pm25 is not None: + analysis['weather']['pm25'] = { + 'value': pm25, + 'impact': (pm25 - 75) * self.weights['pm25'] * 0.5 if pm25 > 75 else 0 + } + + return analysis diff --git a/services/notification/__init__.py b/services/notification/__init__.py new file mode 100644 index 0000000..76f9c2c --- /dev/null +++ b/services/notification/__init__.py @@ -0,0 +1,15 @@ +# =================================================================== +# services/notification/__init__.py +# 알림 서비스 패키지 초기화 +# =================================================================== +# Notion 웹훅 처리 및 다양한 알림 서비스를 제공합니다. +# =================================================================== + +from .notion import NotionWebhookHandler, get_page_details +from .mattermost import MattermostNotifier + +__all__ = [ + 'NotionWebhookHandler', + 'get_page_details', + 'MattermostNotifier', +] diff --git a/services/notification/mattermost.py b/services/notification/mattermost.py new file mode 100644 index 0000000..02ebcc5 --- /dev/null +++ b/services/notification/mattermost.py @@ -0,0 +1,335 @@ +# =================================================================== +# services/notification/mattermost.py +# Mattermost 알림 서비스 모듈 +# =================================================================== +# Mattermost로 알림 메시지를 발송하는 전용 서비스입니다. +# 웹훅 및 API 방식을 모두 지원합니다. +# =================================================================== +""" +Mattermost 알림 서비스 모듈 + +Mattermost 채널로 알림 메시지를 발송합니다. +웹훅과 Bot API 두 가지 방식을 지원합니다. + +사용 예시: + from services.notification.mattermost import MattermostNotifier + + notifier = MattermostNotifier.from_config() + notifier.send_message("서버 점검 알림입니다.") +""" + +from typing import Dict, List, Optional, Any + +import requests + +from core.logging_utils import get_logger +from core.config import get_config + +logger = get_logger(__name__) + + +class MattermostNotifier: + """ + Mattermost 알림 발송 클래스 + + Mattermost 채널로 메시지를 발송합니다. + 웹훅 방식과 Bot API 방식을 모두 지원합니다. + + Attributes: + server_url: Mattermost 서버 URL + bot_token: Bot 인증 토큰 + channel_id: 기본 채널 ID + webhook_url: 웹훅 URL + """ + + def __init__( + self, + server_url: str = "", + bot_token: str = "", + channel_id: str = "", + webhook_url: str = "" + ): + """ + Args: + server_url: Mattermost 서버 URL (예: https://mattermost.example.com) + bot_token: Bot 인증 토큰 + channel_id: 기본 채널 ID + webhook_url: Incoming 웹훅 URL + """ + self.server_url = server_url.rstrip('/') if server_url else "" + self.bot_token = bot_token + self.channel_id = channel_id + self.webhook_url = webhook_url + + @classmethod + def from_config(cls) -> 'MattermostNotifier': + """ + 설정에서 인스턴스 생성 + + Returns: + 설정이 적용된 MattermostNotifier 인스턴스 + """ + config = get_config() + mm_config = config.mattermost + + return cls( + server_url=mm_config.get('url', ''), + bot_token=mm_config.get('token', ''), + channel_id=mm_config.get('channel_id', ''), + webhook_url=mm_config.get('webhook_url', ''), + ) + + def _validate_api_config(self) -> bool: + """API 방식 설정 검증""" + if not self.server_url: + logger.error("Mattermost 서버 URL이 설정되지 않았습니다.") + return False + if not self.server_url.startswith(('http://', 'https://')): + logger.error(f"유효하지 않은 서버 URL: {self.server_url}") + return False + if not self.bot_token: + logger.error("Bot 토큰이 설정되지 않았습니다.") + return False + if not self.channel_id: + logger.error("채널 ID가 설정되지 않았습니다.") + return False + return True + + def send_message( + self, + message: str, + channel_id: Optional[str] = None, + use_webhook: bool = False, + attachments: Optional[List[Dict]] = None, + props: Optional[Dict] = None + ) -> bool: + """ + 메시지 발송 + + Args: + message: 발송할 메시지 + channel_id: 채널 ID (None이면 기본 채널) + use_webhook: 웹훅 사용 여부 + attachments: 첨부 데이터 (Mattermost 형식) + props: 추가 속성 + + Returns: + 발송 성공 여부 + """ + if use_webhook: + return self._send_via_webhook(message, attachments, props) + else: + return self._send_via_api(message, channel_id, attachments, props) + + def _send_via_webhook( + self, + message: str, + attachments: Optional[List[Dict]] = None, + props: Optional[Dict] = None + ) -> bool: + """ + 웹훅으로 메시지 발송 + + Args: + message: 발송할 메시지 + attachments: 첨부 데이터 + props: 추가 속성 + + Returns: + 발송 성공 여부 + """ + if not self.webhook_url: + logger.error("웹훅 URL이 설정되지 않았습니다.") + return False + + payload = {"text": message} + + if attachments: + payload["attachments"] = attachments + if props: + payload["props"] = props + + try: + response = requests.post( + self.webhook_url, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=10 + ) + + if response.status_code == 200: + logger.info("Mattermost 웹훅 전송 성공") + return True + else: + logger.error(f"웹훅 전송 실패: {response.status_code} - {response.text}") + return False + + except requests.exceptions.Timeout: + logger.error("웹훅 전송 타임아웃") + return False + except requests.exceptions.RequestException as e: + logger.error(f"웹훅 전송 예외: {e}") + return False + + def _send_via_api( + self, + message: str, + channel_id: Optional[str] = None, + attachments: Optional[List[Dict]] = None, + props: Optional[Dict] = None + ) -> bool: + """ + Bot API로 메시지 발송 + + Args: + message: 발송할 메시지 + channel_id: 채널 ID + attachments: 첨부 데이터 + props: 추가 속성 + + Returns: + 발송 성공 여부 + """ + if not self._validate_api_config(): + return False + + target_channel = channel_id or self.channel_id + url = f"{self.server_url}/api/v4/posts" + + headers = { + "Authorization": f"Bearer {self.bot_token}", + "Content-Type": "application/json" + } + + payload = { + "channel_id": target_channel, + "message": message + } + + if attachments: + payload["props"] = payload.get("props", {}) + payload["props"]["attachments"] = attachments + if props: + payload["props"] = {**payload.get("props", {}), **props} + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + + if response.status_code in [200, 201]: + logger.info("Mattermost API 전송 성공") + return True + else: + logger.error(f"API 전송 실패: {response.status_code} - {response.text}") + return False + + except requests.exceptions.Timeout: + logger.error("API 전송 타임아웃") + return False + except requests.exceptions.RequestException as e: + logger.error(f"API 전송 예외: {e}") + return False + + def send_formatted_message( + self, + title: str, + text: str, + color: str = "#3498db", + fields: Optional[List[Dict]] = None, + channel_id: Optional[str] = None, + use_webhook: bool = False + ) -> bool: + """ + 서식화된 메시지 발송 (Attachment 사용) + + Args: + title: 메시지 제목 + text: 메시지 본문 + color: 테두리 색상 (hex) + fields: 필드 목록 [{"title": "", "value": "", "short": bool}] + channel_id: 채널 ID + use_webhook: 웹훅 사용 여부 + + Returns: + 발송 성공 여부 + """ + attachment = { + "fallback": f"{title}: {text}", + "color": color, + "title": title, + "text": text, + } + + if fields: + attachment["fields"] = fields + + return self.send_message( + message="", + channel_id=channel_id, + use_webhook=use_webhook, + attachments=[attachment] + ) + + def send_alert( + self, + title: str, + message: str, + level: str = "info", + channel_id: Optional[str] = None + ) -> bool: + """ + 알림 메시지 발송 (레벨에 따른 색상) + + Args: + title: 알림 제목 + message: 알림 내용 + level: 알림 레벨 (info, warning, error, success) + channel_id: 채널 ID + + Returns: + 발송 성공 여부 + """ + colors = { + "info": "#3498db", + "warning": "#f39c12", + "error": "#e74c3c", + "success": "#27ae60" + } + + icons = { + "info": "ℹ️", + "warning": "⚠️", + "error": "🚨", + "success": "✅" + } + + color = colors.get(level, colors["info"]) + icon = icons.get(level, icons["info"]) + + return self.send_formatted_message( + title=f"{icon} {title}", + text=message, + color=color, + channel_id=channel_id + ) + + +def send_mattermost_notification( + message: str, + channel_id: Optional[str] = None, + use_webhook: bool = False +) -> bool: + """ + Mattermost 알림 간편 함수 + + 설정에서 자동으로 설정을 로드하여 메시지를 발송합니다. + + Args: + message: 발송할 메시지 + channel_id: 채널 ID (None이면 기본 채널) + use_webhook: 웹훅 사용 여부 + + Returns: + 발송 성공 여부 + """ + notifier = MattermostNotifier.from_config() + return notifier.send_message(message, channel_id, use_webhook) diff --git a/services/notification/notion.py b/services/notification/notion.py new file mode 100644 index 0000000..a1e3015 --- /dev/null +++ b/services/notification/notion.py @@ -0,0 +1,344 @@ +# =================================================================== +# services/notification/notion.py +# Notion 웹훅 처리 서비스 모듈 +# =================================================================== +# Notion 웹훅 이벤트를 처리하고 알림 메시지를 생성합니다. +# 페이지 생성, 수정, 삭제 등의 이벤트를 지원합니다. +# =================================================================== +""" +Notion 웹훅 처리 서비스 모듈 + +Notion 웹훅 이벤트를 수신하고 처리하여 알림 메시지를 생성합니다. + +사용 예시: + from services.notification.notion import NotionWebhookHandler + + handler = NotionWebhookHandler(api_secret) + message = handler.handle_event(event_data) +""" + +import json +import logging +from datetime import datetime +from typing import Dict, List, Optional, Any + +import requests + +from core.logging_utils import get_logger +from core.config import get_config + +logger = get_logger(__name__) + +# Notion API 설정 +NOTION_API_BASE = "https://api.notion.com/v1" +NOTION_VERSION = "2022-06-28" + +# 이벤트 타입별 라벨 +EVENT_TYPE_LABELS = { + "page.created": "📝 새 페이지 생성", + "page.content_updated": "✏️ 페이지 내용 수정", + "page.properties_updated": "🔄 페이지 속성 변경", + "page.deleted": "🗑️ 페이지 삭제", + "page.restored": "♻️ 페이지 복구", + "page.moved": "📁 페이지 이동", + "page.locked": "🔒 페이지 잠금", + "page.unlocked": "🔓 페이지 잠금 해제", + "database.created": "📊 새 데이터베이스 생성", + "database.content_updated": "📊 데이터베이스 업데이트", + "block.created": "➕ 블록 생성", + "block.changed": "📝 블록 변경", + "block.deleted": "➖ 블록 삭제", + "comment.created": "💬 새 댓글", + "comment.updated": "💬 댓글 수정", + "comment.deleted": "💬 댓글 삭제", +} + + +def get_page_details(page_id: str, api_secret: str) -> Optional[Dict]: + """ + Notion 페이지 상세 정보 조회 + + Args: + page_id: 페이지 ID + api_secret: Notion API 시크릿 + + Returns: + 페이지 상세 정보 또는 None + """ + url = f"{NOTION_API_BASE}/pages/{page_id}" + headers = { + "Authorization": f"Bearer {api_secret}", + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json" + } + + try: + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + return response.json() + else: + logger.error(f"페이지 조회 실패: {response.status_code} - {response.text}") + return None + + except Exception as e: + logger.error(f"페이지 조회 예외: {e}") + return None + + +def get_database_details(database_id: str, api_secret: str) -> Optional[Dict]: + """ + Notion 데이터베이스 상세 정보 조회 + + Args: + database_id: 데이터베이스 ID + api_secret: Notion API 시크릿 + + Returns: + 데이터베이스 상세 정보 또는 None + """ + url = f"{NOTION_API_BASE}/databases/{database_id}" + headers = { + "Authorization": f"Bearer {api_secret}", + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json" + } + + try: + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + return response.json() + else: + logger.error(f"데이터베이스 조회 실패: {response.status_code}") + return None + + except Exception as e: + logger.error(f"데이터베이스 조회 예외: {e}") + return None + + +class NotionWebhookHandler: + """ + Notion 웹훅 이벤트 핸들러 + + Notion에서 전송하는 웹훅 이벤트를 처리하고 + 알림 메시지를 생성합니다. + + Attributes: + api_secret: Notion API 시크릿 + allowed_workspaces: 허용된 워크스페이스 이름 목록 + """ + + def __init__( + self, + api_secret: Optional[str] = None, + allowed_workspaces: Optional[List[str]] = None + ): + """ + Args: + api_secret: Notion API 시크릿 (None이면 설정에서 로드) + allowed_workspaces: 허용된 워크스페이스 목록 (None이면 모두 허용) + """ + if api_secret is None: + config = get_config() + api_secret = config.notion.get('api_secret', '') + + self.api_secret = api_secret + self.allowed_workspaces = allowed_workspaces or [] + + self.headers = { + "Authorization": f"Bearer {self.api_secret}", + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json" + } + + def fetch_entity_detail(self, entity_type: str, entity_id: str) -> Optional[Dict]: + """ + 엔티티 상세 정보 조회 + + Args: + entity_type: 엔티티 타입 ('page', 'database', 'block' 등) + entity_id: 엔티티 ID + + Returns: + 엔티티 상세 정보 또는 None + """ + if entity_type == "page": + url = f"{NOTION_API_BASE}/pages/{entity_id}" + elif entity_type == "database": + url = f"{NOTION_API_BASE}/databases/{entity_id}" + elif entity_type == "block": + url = f"{NOTION_API_BASE}/blocks/{entity_id}" + else: + logger.warning(f"지원하지 않는 엔티티 타입: {entity_type}") + return None + + try: + response = requests.get(url, headers=self.headers, timeout=10) + + if response.status_code == 200: + return response.json() + else: + logger.error(f"엔티티 조회 실패: {response.status_code}") + return None + + except Exception as e: + logger.error(f"엔티티 조회 예외: {e}") + return None + + def extract_title_from_properties(self, properties: Dict) -> str: + """ + 속성에서 제목 추출 + + Args: + properties: Notion 속성 딕셔너리 + + Returns: + 제목 문자열 (없으면 기본값) + """ + # 일반적인 제목 속성 이름들 + title_keys = ['Name', 'name', '이름', '제목', 'Title', '작업 이름'] + + for key in title_keys: + if key in properties: + prop = properties[key] + if prop.get('type') == 'title': + title_arr = prop.get('title', []) + if title_arr: + return ''.join(t.get('plain_text', '') for t in title_arr) + + # title 타입 속성 찾기 + for prop in properties.values(): + if prop.get('type') == 'title': + title_arr = prop.get('title', []) + if title_arr: + return ''.join(t.get('plain_text', '') for t in title_arr) + + return "(제목 없음)" + + def extract_editor_from_properties(self, properties: Dict) -> str: + """ + 속성에서 편집자 정보 추출 + + Args: + properties: Notion 속성 딕셔너리 + + Returns: + 편집자 이름 (없으면 기본값) + """ + editor_keys = ['최종 편집자', 'Last edited by', 'Editor'] + + for key in editor_keys: + if key in properties: + prop = properties[key] + if prop.get('type') == 'last_edited_by': + editor = prop.get('last_edited_by', {}) + return editor.get('name', '(알 수 없음)') + + return "(알 수 없음)" + + def handle_event(self, event: Dict) -> Optional[str]: + """ + Notion 웹훅 이벤트 처리 + + Args: + event: 웹훅 이벤트 데이터 + + Returns: + 알림 메시지 또는 None (무시할 이벤트) + """ + # 이벤트 로깅 + logger.info(f"수신된 Notion 이벤트: {json.dumps(event, ensure_ascii=False)[:500]}") + + # 워크스페이스 확인 + workspace_id = event.get('workspace_id') + if not workspace_id: + logger.warning("workspace_id 없음 - 이벤트 무시") + return None + + # 허용된 워크스페이스 확인 (설정된 경우) + if self.allowed_workspaces: + workspace_name = self.get_workspace_name(workspace_id) + if workspace_name not in self.allowed_workspaces: + logger.info(f"허용되지 않은 워크스페이스: {workspace_name}") + return None + + # 이벤트 정보 추출 + event_type = event.get('type', 'unknown') + timestamp = event.get('timestamp') + + # 시간 포맷팅 + readable_time = None + if timestamp: + try: + readable_time = datetime.fromisoformat( + timestamp.replace('Z', '+00:00') + ).strftime('%Y-%m-%d %H:%M:%S') + except Exception: + readable_time = timestamp + + # 엔티티 정보 + entity = event.get('entity', {}) + entity_id = entity.get('id', 'unknown') + entity_type = entity.get('type', 'unknown') + + # 상세 정보 조회 + detail = self.fetch_entity_detail(entity_type, entity_id) + + if not detail: + logger.warning("상세 정보 조회 실패") + return None + + # 정보 추출 + properties = detail.get('properties', {}) + page_title = self.extract_title_from_properties(properties) + editor_name = self.extract_editor_from_properties(properties) + page_url = detail.get('url', 'URL 없음') + + # 이벤트 라벨 + event_label = EVENT_TYPE_LABELS.get(event_type, f"📢 Notion 이벤트: `{event_type}`") + + # 메시지 구성 + message = ( + f"{event_label}\n" + f"- 🕒 시간: {readable_time or '알 수 없음'}\n" + f"- 📄 페이지: {page_title}\n" + f"- 👤 작업자: {editor_name}\n" + f"- 🔗 [바로가기]({page_url})" + ) + + logger.info(f"생성된 메시지: {message[:200]}...") + return message + + def get_workspace_name(self, workspace_id: str) -> str: + """ + 워크스페이스 이름 조회 + + Note: Notion API에서 별도의 워크스페이스 조회 엔드포인트가 없어 + 현재는 하드코딩되어 있습니다. + + Args: + workspace_id: 워크스페이스 ID + + Returns: + 워크스페이스 이름 + """ + # TODO: 실제 API가 지원되면 구현 + return "퍼스트가든" + + def create_summary_from_page(self, page_data: Dict) -> str: + """ + 페이지 데이터에서 요약 메시지 생성 + + Args: + page_data: 페이지 상세 데이터 + + Returns: + 요약 메시지 + """ + properties = page_data.get('properties', {}) + title = self.extract_title_from_properties(properties) + url = page_data.get('url', 'URL 없음') + + return f"📌 노션 페이지 업데이트됨\n**제목**: {title}\n🔗 [바로가기]({url})" diff --git a/services/weather/__init__.py b/services/weather/__init__.py new file mode 100644 index 0000000..5332542 --- /dev/null +++ b/services/weather/__init__.py @@ -0,0 +1,31 @@ +# =================================================================== +# services/weather/__init__.py +# 기상 데이터 서비스 패키지 초기화 +# =================================================================== +# 기상청 API를 통한 날씨 데이터 수집 및 처리 서비스입니다. +# 초단기예보, 단기예보, 중기예보, ASOS 종관기상 데이터를 지원합니다. +# =================================================================== + +from .forecast import ( + get_ultra_forecast, + get_vilage_forecast, + get_daily_ultra_forecast, + get_daily_vilage_forecast, + get_midterm_forecast, + get_midterm_temperature, + get_weekly_precip, +) +from .asos import get_asos_weather +from .precipitation import PrecipitationService + +__all__ = [ + 'get_ultra_forecast', + 'get_vilage_forecast', + 'get_daily_ultra_forecast', + 'get_daily_vilage_forecast', + 'get_midterm_forecast', + 'get_midterm_temperature', + 'get_weekly_precip', + 'get_asos_weather', + 'PrecipitationService', +] diff --git a/services/weather/asos.py b/services/weather/asos.py new file mode 100644 index 0000000..884fa01 --- /dev/null +++ b/services/weather/asos.py @@ -0,0 +1,383 @@ +# =================================================================== +# services/weather/asos.py +# 기상청 ASOS 종관기상 데이터 서비스 모듈 +# =================================================================== +# 기상청 종관기상관측(ASOS) API를 통해 일별 기상 데이터를 조회합니다. +# 과거 날씨 데이터 수집 및 DB 저장 기능을 제공합니다. +# =================================================================== +""" +기상청 ASOS 종관기상 데이터 서비스 모듈 + +기상청 공공데이터포털의 종관기상관측(ASOS) API를 통해 +일별 기상 데이터(기온, 강수량, 습도 등)를 조회합니다. + +사용 예시: + from services.weather.asos import get_asos_weather, ASOSDataCollector + + # 단일 기간 조회 + data = get_asos_weather(service_key, stn_id=99, start_dt='20240101', end_dt='20240131') + + # 수집기를 통한 자동 데이터 수집 + collector = ASOSDataCollector(service_key) + collector.collect_and_save([99]) +""" + +import traceback +from datetime import datetime, timedelta, date +from typing import Dict, List, Optional, Generator, Tuple, Any + +from sqlalchemy import select, Table +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.engine import Connection + +from core.logging_utils import get_logger +from core.http_client import create_retry_session +from core.config import get_config + +logger = get_logger(__name__) + +# ASOS API URL +ASOS_API_URL = "http://apis.data.go.kr/1360000/AsosDalyInfoService/getWthrDataList" + +# 시간 관련 컬럼 (음수 값을 null로 처리) +HRMT_KEYS = [ + "minTaHrmt", "maxTaHrmt", "mi10MaxRnHrmt", "hr1MaxRnHrmt", + "maxInsWsHrmt", "maxWsHrmt", "minRhmHrmt", "maxPsHrmt", + "minPsHrmt", "hr1MaxIcsrHrmt", "ddMefsHrmt", "ddMesHrmt" +] + + +def fetch_date_range_chunks( + start_dt: str, + end_dt: str, + chunk_days: int = 10 +) -> Generator[Tuple[str, str], None, None]: + """ + 날짜 범위를 청크 단위로 분할 + + 대량의 데이터를 조회할 때 API 요청을 분할하여 처리합니다. + + Args: + start_dt: 시작 날짜 (YYYYMMDD) + end_dt: 종료 날짜 (YYYYMMDD) + chunk_days: 청크 크기 (일 단위) + + Yields: + (시작일, 종료일) 튜플 + """ + current_start = datetime.strptime(start_dt, "%Y%m%d") + end_date = datetime.strptime(end_dt, "%Y%m%d") + + while current_start <= end_date: + current_end = min(current_start + timedelta(days=chunk_days - 1), end_date) + yield current_start.strftime("%Y%m%d"), current_end.strftime("%Y%m%d") + current_start = current_end + timedelta(days=1) + + +def get_asos_weather( + service_key: str, + stn_id: int, + start_dt: str, + end_dt: str, + session = None +) -> List[Dict]: + """ + ASOS 종관기상 데이터 조회 + + 기상청 ASOS API를 호출하여 일별 기상 데이터를 조회합니다. + + Args: + service_key: 공공데이터포털 API 키 + stn_id: 관측 지점 ID + start_dt: 시작 날짜 (YYYYMMDD) + end_dt: 종료 날짜 (YYYYMMDD) + session: requests 세션 (재사용용) + + Returns: + 일별 기상 데이터 리스트 + + 데이터 항목: + - tm: 일시 (YYYY-MM-DD) + - avgTa: 평균 기온 (℃) + - minTa: 최저 기온 (℃) + - maxTa: 최고 기온 (℃) + - sumRn: 일 강수량 (mm) + - avgRhm: 평균 상대습도 (%) + - avgWs: 평균 풍속 (m/s) + 등 + """ + if session is None: + session = create_retry_session(retries=3) + + params = { + "serviceKey": service_key, + "pageNo": "1", + "numOfRows": "500", + "dataType": "JSON", + "dataCd": "ASOS", + "dateCd": "DAY", + "startDt": start_dt, + "endDt": end_dt, + "stnIds": str(stn_id), + } + + headers = { + "User-Agent": "Mozilla/5.0", + "Accept": "application/json" + } + + try: + response = session.get(ASOS_API_URL, params=params, headers=headers, timeout=20) + response.raise_for_status() + + data = response.json() + items = data.get("response", {}).get("body", {}).get("items", {}).get("item", []) + + if items is None: + return [] + + # 단일 항목인 경우 리스트로 변환 + if isinstance(items, dict): + items = [items] + + logger.debug(f"ASOS 데이터 조회 완료: 지점={stn_id}, 건수={len(items)}") + return items + + except Exception as e: + logger.error(f"ASOS API 요청 실패: {e}") + traceback.print_exc() + return [] + + +class ASOSDataCollector: + """ + ASOS 데이터 자동 수집기 + + 설정에 따라 ASOS 데이터를 자동으로 수집하고 DB에 저장합니다. + 마지막 저장 일자를 확인하여 중복 없이 증분 수집합니다. + + Attributes: + service_key: API 서비스 키 + force_update: 기존 데이터 덮어쓰기 여부 + debug: 디버그 모드 (True면 실제 저장 안 함) + """ + + def __init__( + self, + service_key: Optional[str] = None, + force_update: bool = False, + debug: bool = False + ): + """ + Args: + service_key: API 키 (None이면 설정에서 로드) + force_update: 기존 데이터 덮어쓰기 여부 + debug: 디버그 모드 + """ + if service_key is None: + config = get_config() + service_key = config.data_api['service_key'] + + self.service_key = service_key + self.force_update = force_update + self.debug = debug + self.session = create_retry_session(retries=3) + + def get_latest_date_from_db(self, conn: Connection, table: Table) -> Optional[date]: + """ + DB에서 가장 최근 저장된 날짜 조회 + + Args: + conn: DB 연결 + table: 대상 테이블 + + Returns: + 최근 날짜 또는 None + """ + sel = select(table.c.date).order_by(table.c.date.desc()).limit(1) + result = conn.execute(sel).fetchone() + return result[0] if result else None + + def parse_item_to_record(self, item: Dict, table: Table) -> Optional[Dict]: + """ + API 응답 아이템을 DB 레코드로 변환 + + Args: + item: API 응답 아이템 + table: 대상 테이블 + + Returns: + DB 레코드 딕셔너리 또는 None + """ + date_str = item.get("tm") + if not date_str: + return None + + try: + record_date = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + logger.warning(f"날짜 파싱 실패: {date_str}") + return None + + data = {"date": record_date} + + for key in table.c.keys(): + if key == "date": + continue + + value = item.get(key) + + # 빈 값 처리 + if value in ["", None, "-"]: + data[key] = None + continue + + try: + # 시간 관련 컬럼 또는 특수 컬럼 처리 + if key in HRMT_KEYS or key == "iscs": + fval = float(value) + data[key] = str(int(fval)) if fval >= 0 else None + elif key == "stnId": + data[key] = int(float(value)) + else: + data[key] = float(value) + except (ValueError, TypeError): + data[key] = None + + return data + + def save_items_to_db( + self, + items: List[Dict], + conn: Connection, + table: Table + ) -> int: + """ + 데이터 항목들을 DB에 저장 + + Args: + items: 저장할 데이터 리스트 + conn: DB 연결 + table: 대상 테이블 + + Returns: + 저장된 레코드 수 + """ + saved_count = 0 + + for item in items: + data = self.parse_item_to_record(item, table) + if not data: + continue + + record_date = data['date'] + + if self.debug: + logger.debug(f"[DEBUG] {record_date} DB 저장 시도: {data}") + continue + + try: + if self.force_update: + # UPSERT: 존재하면 업데이트, 없으면 삽입 + stmt = mysql_insert(table).values(**data) + stmt = stmt.on_duplicate_key_update(**data) + conn.execute(stmt) + logger.info(f"{record_date} 저장/업데이트 완료") + else: + # 중복 확인 후 삽입 + sel = select(table.c.date).where(table.c.date == record_date) + if conn.execute(sel).fetchone(): + logger.debug(f"{record_date} 이미 존재, 저장 생략") + continue + + conn.execute(table.insert().values(**data)) + logger.info(f"{record_date} 저장 완료") + + saved_count += 1 + + except Exception as e: + logger.error(f"저장 실패 ({record_date}): {e}") + traceback.print_exc() + raise + + return saved_count + + def collect_and_save( + self, + stn_ids: List[int], + engine, + table: Table, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + chunk_days: int = 1000 + ) -> int: + """ + 데이터 수집 및 저장 실행 + + Args: + stn_ids: 관측 지점 ID 리스트 + engine: SQLAlchemy 엔진 + table: 대상 테이블 + start_date: 시작 날짜 (None이면 자동 계산) + end_date: 종료 날짜 (None이면 자동 계산) + chunk_days: 청크 크기 + + Returns: + 총 저장된 레코드 수 + """ + now = datetime.now() + today = now.date() + + # 종료일 계산 (오전 11시 이전이면 전전일, 이후면 전일) + if end_date is None: + if now.hour < 11: + end_dt = (today - timedelta(days=2)).strftime("%Y%m%d") + logger.info(f"오전 11시 이전, 전전일까지 조회: {end_dt}") + else: + end_dt = (today - timedelta(days=1)).strftime("%Y%m%d") + logger.info(f"전일까지 조회: {end_dt}") + else: + end_dt = end_date + + total_saved = 0 + + with engine.begin() as conn: + # 시작일 계산 + if start_date is None: + latest_date = self.get_latest_date_from_db(conn, table) + if latest_date: + start_dt = (latest_date + timedelta(days=1)).strftime("%Y%m%d") + logger.info(f"마지막 저장일: {latest_date}, 시작일: {start_dt}") + else: + config = get_config() + start_dt = config.data_api.get('start_date', '20170101') + logger.info(f"저장된 데이터 없음, 기본 시작일: {start_dt}") + else: + start_dt = start_date + + # 날짜 검증 + if start_dt > end_dt: + logger.info("최신 데이터가 이미 존재합니다.") + return 0 + + # 각 관측 지점별 데이터 수집 + for stn_id in stn_ids: + for chunk_start, chunk_end in fetch_date_range_chunks(start_dt, end_dt, chunk_days): + logger.info(f"지점 {stn_id} 데이터 요청: {chunk_start} ~ {chunk_end}") + + items = get_asos_weather( + self.service_key, + stn_id, + chunk_start, + chunk_end, + self.session + ) + + if items: + saved = self.save_items_to_db(items, conn, table) + total_saved += saved + else: + logger.warning(f"지점 {stn_id} {chunk_start}~{chunk_end} 데이터 없음") + + logger.info(f"총 {total_saved}건 저장 완료") + return total_saved diff --git a/services/weather/forecast.py b/services/weather/forecast.py new file mode 100644 index 0000000..4c75fb6 --- /dev/null +++ b/services/weather/forecast.py @@ -0,0 +1,530 @@ +# =================================================================== +# services/weather/forecast.py +# 기상청 예보 API 서비스 모듈 +# =================================================================== +# 기상청 공공데이터포털 API를 통해 다양한 예보 데이터를 조회합니다: +# - 초단기예보: 향후 6시간 이내 예보 +# - 단기예보: 향후 3일간 예보 +# - 중기예보: 3~10일 후 예보 +# =================================================================== +""" +기상청 예보 API 서비스 모듈 + +기상청 공공데이터포털 API를 통해 초단기, 단기, 중기 예보 데이터를 조회합니다. + +사용 예시: + from services.weather.forecast import get_daily_vilage_forecast, get_midterm_forecast + + # 단기 예보 조회 + forecast = get_daily_vilage_forecast(service_key) + + # 중기 예보 조회 + precip_probs, raw_data = get_midterm_forecast(service_key) +""" + +import re +from datetime import datetime, timedelta, date +from typing import Dict, List, Optional, Tuple, Any + +import requests + +from core.logging_utils import get_logger +from core.http_client import create_retry_session + +logger = get_logger(__name__) + +# 기상청 API 기본 URL +VILAGE_FCST_URL = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0" +MID_FCST_URL = "http://apis.data.go.kr/1360000/MidFcstInfoService" + +# 기본 좌표 (파주 지역) +DEFAULT_NX = 57 +DEFAULT_NY = 130 + + +def parse_precip(value: Any) -> float: + """ + 강수량 텍스트를 숫자(mm)로 변환 + + 기상청 API에서 반환하는 강수량 텍스트를 파싱합니다. + + Args: + value: 강수량 값 ('강수없음', '1mm 미만', '3.5mm' 등) + + Returns: + 강수량 (mm). 파싱 실패 시 0.0 + + Examples: + >>> parse_precip('강수없음') + 0.0 + >>> parse_precip('1mm 미만') + 0.5 + >>> parse_precip('3.5') + 3.5 + """ + if value == '강수없음': + return 0.0 + elif '1mm 미만' in str(value): + return 0.5 + else: + # 숫자 추출 시도 + match = re.search(r"[\d.]+", str(value)) + if match: + try: + return float(match.group()) + except ValueError: + pass + try: + return float(value) + except (ValueError, TypeError): + return 0.0 + + +def get_latest_base_datetime(now: Optional[datetime] = None) -> Tuple[str, str]: + """ + 최신 발표 시각 계산 + + 단기예보 API의 base_time은 특정 시간에만 발표됩니다. + 현재 시간 기준 가장 최근 발표 시각을 계산합니다. + + Args: + now: 기준 시간 (None이면 현재 시간) + + Returns: + (base_date, base_time) 튜플 (YYYYMMDD, HHMM) + """ + if now is None: + now = datetime.now() + + # 발표 시각 목록 (하루 8회) + base_times = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"] + + # 현재 시간에 맞는 가장 최근 발표 시각 찾기 + candidate = None + for bt in reversed(base_times): + hour = int(bt[:2]) + minute = int(bt[2:]) + + if (now.hour > hour) or (now.hour == hour and now.minute >= minute + 10): + candidate = bt + break + + # 적합한 시각이 없으면 전날 마지막 발표 사용 + if candidate is None: + candidate = "2300" + now -= timedelta(days=1) + + base_date = now.strftime("%Y%m%d") + return base_date, candidate + + +def get_ultra_forecast( + service_key: str, + base_date: Optional[str] = None, + base_time: Optional[str] = None, + nx: int = DEFAULT_NX, + ny: int = DEFAULT_NY +) -> List[Dict]: + """ + 초단기예보 조회 (향후 6시간) + + 기상청 초단기예보 API를 호출하여 원시 데이터를 반환합니다. + + Args: + service_key: 공공데이터포털 API 키 + base_date: 발표 날짜 (YYYYMMDD). None이면 현재 날짜 + base_time: 발표 시각 (HHMM). None이면 자동 계산 + nx: 격자 X 좌표 + ny: 격자 Y 좌표 + + Returns: + 예보 아이템 리스트 (기상청 API 원시 응답) + """ + if base_date is None or base_time is None: + base_date, base_time = get_latest_base_datetime() + + url = f"{VILAGE_FCST_URL}/getUltraSrtFcst" + params = { + 'serviceKey': service_key, + 'numOfRows': '1000', + 'pageNo': '1', + 'dataType': 'JSON', + 'base_date': base_date, + 'base_time': base_time, + 'nx': str(nx), + 'ny': str(ny) + } + + try: + session = create_retry_session(retries=3) + response = session.get(url, params=params, timeout=10) + response.raise_for_status() + + data = response.json() + items = data.get('response', {}).get('body', {}).get('items', {}).get('item', []) + + logger.debug(f"초단기예보 조회 완료: {len(items)}건") + return items + + except Exception as e: + logger.error(f"초단기예보 조회 실패: {e}") + return [] + + +def get_vilage_forecast( + service_key: str, + base_date: Optional[str] = None, + base_time: str = "0200", + nx: int = DEFAULT_NX, + ny: int = DEFAULT_NY +) -> List[Dict]: + """ + 단기예보 조회 (향후 3일) + + 기상청 단기예보 API를 호출하여 원시 데이터를 반환합니다. + + Args: + service_key: 공공데이터포털 API 키 + base_date: 발표 날짜 (YYYYMMDD). None이면 현재 날짜 + base_time: 발표 시각 (기본: 0200) + nx: 격자 X 좌표 + ny: 격자 Y 좌표 + + Returns: + 예보 아이템 리스트 (기상청 API 원시 응답) + """ + if base_date is None: + base_date = datetime.now().strftime("%Y%m%d") + + url = f"{VILAGE_FCST_URL}/getVilageFcst" + params = { + 'serviceKey': service_key, + 'numOfRows': '1000', + 'pageNo': '1', + 'dataType': 'JSON', + 'base_date': base_date, + 'base_time': base_time, + 'nx': str(nx), + 'ny': str(ny) + } + + try: + session = create_retry_session(retries=3) + response = session.get(url, params=params, timeout=10) + response.raise_for_status() + + data = response.json() + items = data.get('response', {}).get('body', {}).get('items', {}).get('item', []) + + logger.debug(f"단기예보 조회 완료: {len(items)}건") + return items + + except Exception as e: + logger.error(f"단기예보 조회 실패: {e}") + return [] + + +def get_daily_ultra_forecast(service_key: str) -> Dict[str, Dict]: + """ + 초단기예보 일별 요약 데이터 조회 + + 초단기예보 데이터를 날짜별로 집계하여 반환합니다. + + Args: + service_key: 공공데이터포털 API 키 + + Returns: + 날짜별 요약 딕셔너리 + {날짜: {'sumRn': 강수량합계, 'minTa': 최저기온, 'maxTa': 최고기온, 'avgRhm': 평균습도}} + """ + base_date, base_time = get_latest_base_datetime() + items = get_ultra_forecast(service_key, base_date, base_time) + + if not items: + return {} + + # 날짜별 데이터 집계 + daily_data: Dict[str, Dict] = {} + + for item in items: + dt = item.get('fcstDate', '') + cat = item.get('category', '') + val = item.get('fcstValue', '') + + if not dt: + continue + + if dt not in daily_data: + daily_data[dt] = {'sumRn': 0, 'temps': [], 'rhm': []} + + # 카테고리별 처리 + if cat == 'RN1': # 1시간 강수량 + daily_data[dt]['sumRn'] += parse_precip(val) + elif cat == 'T1H': # 기온 + try: + daily_data[dt]['temps'].append(float(val)) + except (ValueError, TypeError): + pass + elif cat == 'REH': # 습도 + try: + daily_data[dt]['rhm'].append(float(val)) + except (ValueError, TypeError): + pass + + # 집계 결과 변환 + result = {} + for dt, vals in daily_data.items(): + temps = vals['temps'] + rhm = vals['rhm'] + + result[dt] = { + 'sumRn': round(vals['sumRn'], 2), + 'minTa': min(temps) if temps else 0, + 'maxTa': max(temps) if temps else 0, + 'avgRhm': round(sum(rhm) / len(rhm), 1) if rhm else 0 + } + + return result + + +def get_daily_vilage_forecast(service_key: str) -> Dict[str, Dict]: + """ + 단기예보 일별 요약 데이터 조회 + + 단기예보 데이터를 날짜별로 집계하여 반환합니다. + + Args: + service_key: 공공데이터포털 API 키 + + Returns: + 날짜별 요약 딕셔너리 + {날짜: {'sumRn': 강수량합계, 'minTa': 최저기온, 'maxTa': 최고기온, 'avgRhm': 평균습도}} + """ + base_date, _ = get_latest_base_datetime() + items = get_vilage_forecast(service_key, base_date) + + if not items: + return {} + + # 날짜별 데이터 집계 + daily_data: Dict[str, Dict] = {} + + for item in items: + dt = item.get('fcstDate', '') + cat = item.get('category', '') + val = item.get('fcstValue', '') + + if not dt: + continue + + if dt not in daily_data: + daily_data[dt] = {'sumRn': 0, 'minTa': [], 'maxTa': [], 'rhm': []} + + # 카테고리별 처리 + if cat == 'PCP': # 강수량 + daily_data[dt]['sumRn'] += parse_precip(val) + elif cat == 'TMN': # 최저기온 + try: + daily_data[dt]['minTa'].append(float(val)) + except (ValueError, TypeError): + pass + elif cat == 'TMX': # 최고기온 + try: + daily_data[dt]['maxTa'].append(float(val)) + except (ValueError, TypeError): + pass + elif cat == 'REH': # 습도 + try: + daily_data[dt]['rhm'].append(float(val)) + except (ValueError, TypeError): + pass + + # 집계 결과 변환 + result = {} + for dt, vals in daily_data.items(): + min_ta = vals['minTa'] + max_ta = vals['maxTa'] + rhm = vals['rhm'] + + result[dt] = { + 'sumRn': round(vals['sumRn'], 2), + 'minTa': min(min_ta) if min_ta else 0, + 'maxTa': max(max_ta) if max_ta else 0, + 'avgRhm': round(sum(rhm) / len(rhm), 1) if rhm else 0 + } + + return result + + +def get_midterm_forecast( + service_key: str, + reg_id: str = '11B20305' +) -> Tuple[Dict[int, int], Dict]: + """ + 중기 강수확률 예보 조회 (3~10일 후) + + Args: + service_key: 공공데이터포털 API 키 + reg_id: 예보 지역 코드 (기본: 파주) + + Returns: + (강수확률 딕셔너리, 원시 응답) 튜플 + 강수확률: {일수: 확률} (예: {3: 30, 4: 20, ...}) + """ + url = f"{MID_FCST_URL}/getMidLandFcst" + + # 발표 시각 계산 (06시 또는 18시) + now = datetime.now() + if now.hour < 6: + tm_fc = (now - timedelta(days=1)).strftime("%Y%m%d") + "1800" + elif now.hour < 18: + tm_fc = now.strftime("%Y%m%d") + "0600" + else: + tm_fc = now.strftime("%Y%m%d") + "1800" + + params = { + 'serviceKey': service_key, + 'regId': reg_id, + 'tmFc': tm_fc, + 'numOfRows': '10', + 'pageNo': '1', + 'dataType': 'JSON', + } + + try: + session = create_retry_session(retries=3) + response = session.get(url, params=params, timeout=10) + response.raise_for_status() + + data = response.json() + items = data.get('response', {}).get('body', {}).get('items', {}).get('item', []) + + if not items: + logger.warning(f"중기예보 응답 없음: tmFc={tm_fc}, regId={reg_id}") + return {}, {} + + item = items[0] + + # 3~10일 후 강수확률 추출 + precip_probs = {} + for day in range(3, 11): + key = f'rnSt{day}' + try: + precip_probs[day] = int(item.get(key, 0)) + except (ValueError, TypeError): + precip_probs[day] = 0 + + logger.debug(f"중기 강수확률 조회 완료: {precip_probs}") + return precip_probs, item + + except Exception as e: + logger.error(f"중기예보 조회 실패: {e}") + return {}, {} + + +def get_midterm_temperature( + service_key: str, + reg_id: str = '11B20305' +) -> Dict[int, Dict[str, int]]: + """ + 중기 기온 예보 조회 (3~10일 후) + + Args: + service_key: 공공데이터포털 API 키 + reg_id: 예보 지역 코드 (기본: 파주) + + Returns: + 일자별 기온 딕셔너리 + {일수: {'min': 최저기온, 'max': 최고기온}} + """ + url = f"{MID_FCST_URL}/getMidTa" + + # 발표 시각 계산 + now = datetime.now() + if now.hour < 6: + tm_fc = (now - timedelta(days=1)).strftime("%Y%m%d") + "1800" + elif now.hour < 18: + tm_fc = now.strftime("%Y%m%d") + "0600" + else: + tm_fc = now.strftime("%Y%m%d") + "1800" + + params = { + 'serviceKey': service_key, + 'regId': reg_id, + 'tmFc': tm_fc, + 'pageNo': '1', + 'numOfRows': '10', + 'dataType': 'JSON' + } + + try: + session = create_retry_session(retries=3) + response = session.get(url, params=params, timeout=10) + response.raise_for_status() + + data = response.json() + items = data.get('response', {}).get('body', {}).get('items', {}).get('item', []) + + if not items: + logger.warning(f"중기기온예보 응답 없음: tmFc={tm_fc}, regId={reg_id}") + return {} + + item = items[0] + + # 3~10일 후 기온 추출 + temps = {} + for day in range(3, 11): + min_key = f'taMin{day}' + max_key = f'taMax{day}' + try: + temps[day] = { + 'min': int(item.get(min_key, 0)), + 'max': int(item.get(max_key, 0)) + } + except (ValueError, TypeError): + temps[day] = {'min': 0, 'max': 0} + + logger.debug(f"중기 기온 조회 완료: {temps}") + return temps + + except Exception as e: + logger.error(f"중기기온예보 조회 실패: {e}") + return {} + + +def get_weekly_precip(service_key: str) -> Dict[str, float]: + """ + 금주 일요일까지의 일별 예상 강수량 조회 + + 단기예보와 중기예보를 조합하여 금주 일요일까지의 강수량을 예측합니다. + + Args: + service_key: 공공데이터포털 API 키 + + Returns: + 날짜별 예상 강수량 딕셔너리 {YYYYMMDD: 강수량(mm)} + """ + today = date.today() + sunday = today + timedelta(days=(6 - today.weekday())) + + result = {} + + # 단기예보 데이터 (오늘~+2일) + vilage_data = get_daily_vilage_forecast(service_key) + for dt, vals in vilage_data.items(): + dt_date = datetime.strptime(dt, "%Y%m%d").date() + if dt_date <= sunday: + result[dt] = vals['sumRn'] + + # 중기예보 강수확률 (3일 이후) + precip_probs, _ = get_midterm_forecast(service_key) + for day, prob in precip_probs.items(): + target_date = today + timedelta(days=day) + if target_date <= sunday: + dt = target_date.strftime("%Y%m%d") + if dt not in result: + # 강수확률을 기반으로 예상 강수량 추정 + # (간단한 휴리스틱: 확률 * 0.1mm) + result[dt] = round(prob * 0.1, 1) + + return result diff --git a/services/weather/precipitation.py b/services/weather/precipitation.py new file mode 100644 index 0000000..26262ac --- /dev/null +++ b/services/weather/precipitation.py @@ -0,0 +1,343 @@ +# =================================================================== +# services/weather/precipitation.py +# 강수량 데이터 서비스 모듈 +# =================================================================== +# 시간별 강수량 예보 데이터를 조회하고 요약 정보를 생성합니다. +# HTML 테이블 생성 및 SQLite/MySQL 저장을 지원합니다. +# =================================================================== +""" +강수량 데이터 서비스 모듈 + +시간별 강수량 예보 데이터를 조회하고 요약 정보를 생성합니다. +다양한 출력 형식(HTML, JSON, 텍스트)을 지원합니다. + +사용 예시: + from services.weather.precipitation import PrecipitationService + + service = PrecipitationService(service_key) + summary = service.get_daily_summary() + html = service.generate_html_table() +""" + +import os +import sqlite3 +from datetime import datetime +from typing import Dict, List, Optional, Tuple, Any + +from core.logging_utils import get_logger +from core.config import get_config +from .forecast import parse_precip, get_ultra_forecast, get_vilage_forecast + +logger = get_logger(__name__) + + +class PrecipitationService: + """ + 강수량 데이터 서비스 클래스 + + 초단기예보와 단기예보를 조합하여 시간별 강수량 예보를 제공합니다. + + Attributes: + service_key: 기상청 API 서비스 키 + start_hour: 집계 시작 시간 (기본: 10) + end_hour: 집계 종료 시간 (기본: 22) + """ + + def __init__( + self, + service_key: Optional[str] = None, + start_hour: int = 10, + end_hour: int = 22 + ): + """ + Args: + service_key: API 키 (None이면 설정에서 로드) + start_hour: 집계 시작 시간 + end_hour: 집계 종료 시간 + """ + if service_key is None: + config = get_config() + service_key = config.weather_service.get('service_key') or config.data_api.get('service_key', '') + + self.service_key = service_key + self.start_hour = start_hour + self.end_hour = end_hour + + def get_hourly_ultra_data(self, target_date: Optional[str] = None) -> Dict[int, float]: + """ + 초단기예보에서 시간별 강수량 추출 + + Args: + target_date: 대상 날짜 (YYYYMMDD). None이면 오늘 + + Returns: + 시간별 강수량 딕셔너리 {시간: 강수량(mm)} + """ + if target_date is None: + target_date = datetime.now().strftime('%Y%m%d') + + items = get_ultra_forecast(self.service_key) + result = {} + + for item in items: + if item.get('category') != 'RN1': + continue + if item.get('fcstDate') != target_date: + continue + + try: + hour = int(item['fcstTime'][:2]) + if self.start_hour <= hour <= self.end_hour: + result[hour] = parse_precip(item['fcstValue']) + except (ValueError, KeyError): + continue + + return result + + def get_hourly_vilage_data(self, target_date: Optional[str] = None) -> Dict[int, float]: + """ + 단기예보에서 시간별 강수량 추출 + + Args: + target_date: 대상 날짜 (YYYYMMDD). None이면 오늘 + + Returns: + 시간별 강수량 딕셔너리 {시간: 강수량(mm)} + """ + if target_date is None: + target_date = datetime.now().strftime('%Y%m%d') + + items = get_vilage_forecast(self.service_key, base_date=target_date) + result = {} + + for item in items: + if item.get('category') != 'PCP': + continue + if item.get('fcstDate') != target_date: + continue + + try: + hour = int(item['fcstTime'][:2]) + if self.start_hour <= hour <= self.end_hour: + result[hour] = parse_precip(item['fcstValue']) + except (ValueError, KeyError): + continue + + return result + + def get_daily_summary( + self, + target_date: Optional[str] = None + ) -> Tuple[List[Tuple[str, float]], float]: + """ + 일별 강수량 요약 조회 + + 초단기예보를 우선으로 하고, 없는 시간대는 단기예보로 보완합니다. + + Args: + target_date: 대상 날짜 (YYYYMMDD). None이면 오늘 + + Returns: + (시간별 강수량 리스트, 총 강수량) 튜플 + 시간별: [('10:00', 0.5), ('11:00', 0.0), ...] + """ + if target_date is None: + target_date = datetime.now().strftime('%Y%m%d') + + # 초단기예보 데이터 (우선) + ultra_data = self.get_hourly_ultra_data(target_date) + + # 단기예보 데이터 (보완용) + vilage_data = self.get_hourly_vilage_data(target_date) + + time_precip_list = [] + total_rainfall = 0.0 + + for hour in range(self.start_hour, self.end_hour + 1): + # 초단기예보 우선, 없으면 단기예보 사용 + rainfall = ultra_data.get(hour, vilage_data.get(hour, 0.0)) + time_str = f"{hour:02d}:00" + time_precip_list.append((time_str, rainfall)) + total_rainfall += rainfall + + return time_precip_list, round(total_rainfall, 1) + + def generate_html_table( + self, + target_date: Optional[str] = None, + title: Optional[str] = None + ) -> str: + """ + 강수량 요약 HTML 테이블 생성 + + Args: + target_date: 대상 날짜 (YYYYMMDD). None이면 오늘 + title: 테이블 제목 (None이면 기본 제목) + + Returns: + HTML 문자열 + """ + if target_date is None: + target_date = datetime.now().strftime('%Y%m%d') + + time_precip_list, total_rainfall = self.get_daily_summary(target_date) + + if title is None: + title = f"[{self.start_hour}:00 ~ {self.end_hour}:00 예상 강수량]" + + lines = [ + '
', + f'

{title}

', + '', + '', + '', + '', + '' + ] + + for time_str, rainfall in time_precip_list: + lines.append( + f'' + f'' + ) + + lines.append( + f'' + ) + lines.append('
시간강수량
{time_str}{rainfall}mm
' + f'총 예상 강수량: {total_rainfall:.1f}mm
') + lines.append('

초단기 + 단기 예보 기준

') + lines.append('
') + + return ''.join(lines) + + def generate_text_summary( + self, + target_date: Optional[str] = None + ) -> str: + """ + 강수량 요약 텍스트 생성 + + Args: + target_date: 대상 날짜 (YYYYMMDD). None이면 오늘 + + Returns: + 텍스트 요약 문자열 + """ + if target_date is None: + target_date = datetime.now().strftime('%Y%m%d') + + time_precip_list, total_rainfall = self.get_daily_summary(target_date) + + lines = [ + f"📅 {target_date} 예상 강수량", + f"⏰ {self.start_hour}:00 ~ {self.end_hour}:00", + "-" * 20 + ] + + for time_str, rainfall in time_precip_list: + lines.append(f"{time_str} → {rainfall}mm") + + lines.append("-" * 20) + lines.append(f"☔ 총 예상 강수량: {total_rainfall:.1f}mm") + + return "\n".join(lines) + + def save_to_sqlite( + self, + target_date: Optional[str] = None, + db_path: str = '/data/weather.sqlite' + ): + """ + 강수량 데이터를 SQLite에 저장 + + Args: + target_date: 대상 날짜 (YYYYMMDD). None이면 오늘 + db_path: SQLite 데이터베이스 파일 경로 + """ + if target_date is None: + target_date = datetime.now().strftime('%Y%m%d') + + time_precip_list, total_rainfall = self.get_daily_summary(target_date) + + # 디렉토리 생성 + os.makedirs(os.path.dirname(db_path), exist_ok=True) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # 테이블 생성 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS precipitation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + time TEXT NOT NULL, + rainfall REAL NOT NULL + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS precipitation_summary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE, + total_rainfall REAL NOT NULL + ) + ''') + + # 기존 데이터 삭제 + cursor.execute('DELETE FROM precipitation WHERE date = ?', (target_date,)) + cursor.execute('DELETE FROM precipitation_summary WHERE date = ?', (target_date,)) + + # 시간별 데이터 삽입 + cursor.executemany( + 'INSERT INTO precipitation (date, time, rainfall) VALUES (?, ?, ?)', + [(target_date, t, r) for t, r in time_precip_list] + ) + + # 총 강수량 삽입 + cursor.execute( + 'INSERT INTO precipitation_summary (date, total_rainfall) VALUES (?, ?)', + (target_date, total_rainfall) + ) + + conn.commit() + conn.close() + + logger.info(f"SQLite 저장 완료: {target_date}, 총 {total_rainfall}mm") + + def get_precipitation_info( + self, + target_date: Optional[str] = None, + output_format: str = 'dict' + ) -> Any: + """ + 강수량 정보 조회 (다양한 형식 지원) + + Args: + target_date: 대상 날짜 (YYYYMMDD). None이면 오늘 + output_format: 출력 형식 ('dict', 'html', 'text', 'json') + + Returns: + 요청된 형식의 강수량 정보 + """ + if target_date is None: + target_date = datetime.now().strftime('%Y%m%d') + + if output_format == 'html': + return self.generate_html_table(target_date) + elif output_format == 'text': + return self.generate_text_summary(target_date) + elif output_format == 'json': + time_precip_list, total = self.get_daily_summary(target_date) + return { + 'date': target_date, + 'hourly': [{'time': t, 'rainfall': r} for t, r in time_precip_list], + 'total': total + } + else: + time_precip_list, total = self.get_daily_summary(target_date) + return { + 'date': target_date, + 'hourly': time_precip_list, + 'total': total + }