feat: Flask 애플리케이션 모듈화 및 웹 대시보드 구현
- Flask Blueprint 아키텍처로 전환 (dashboard, upload, backup, status) - app.py 681줄 95줄로 축소 (86% 감소) - HTML 템플릿 모듈화 (base.html + 기능별 templates) - CSS/JS 파일 분리 (common + 기능별 파일) - 대시보드 기능 추가 (통계, 주간 예보, 방문객 추이) - 파일 업로드 웹 인터페이스 구현 - 백업/복구 관리 UI 구현 - Docker 배포 환경 개선 - .gitignore 업데이트 (uploads, backups, cache 등)
This commit is contained in:
@ -1,13 +1,17 @@
|
||||
import os, sys
|
||||
import os, sys
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
import traceback
|
||||
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from conf import db, db_schema
|
||||
from requests_utils import make_requests_session
|
||||
|
||||
CACHE_FILE = os.path.join(os.path.dirname(__file__), '..', 'cache', 'air_num_rows.json')
|
||||
|
||||
class AirQualityCollector:
|
||||
def __init__(self, config, engine, table):
|
||||
@ -20,6 +24,24 @@ class AirQualityCollector:
|
||||
self.engine = engine
|
||||
self.table = table
|
||||
self.yesterday = (datetime.now() - timedelta(days=1)).date()
|
||||
self.session = make_requests_session()
|
||||
|
||||
# load cache
|
||||
self._num_rows_cache = {}
|
||||
try:
|
||||
if os.path.exists(CACHE_FILE):
|
||||
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
|
||||
self._num_rows_cache = json.load(f)
|
||||
except Exception:
|
||||
self._num_rows_cache = {}
|
||||
|
||||
def _save_num_rows_cache(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
||||
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._num_rows_cache, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[WARN] num_rows 캐시 저장 실패: {e}")
|
||||
|
||||
def get_latest_date(self, conn, station):
|
||||
try:
|
||||
@ -30,6 +52,7 @@ class AirQualityCollector:
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 가장 최근 날짜 조회 실패: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def save_data_to_db(self, items, conn, station):
|
||||
@ -37,7 +60,7 @@ class AirQualityCollector:
|
||||
try:
|
||||
item_date = datetime.strptime(item['msurDt'], '%Y-%m-%d').date()
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} → {e}")
|
||||
print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} {e}")
|
||||
continue
|
||||
|
||||
data = {
|
||||
@ -53,7 +76,7 @@ class AirQualityCollector:
|
||||
|
||||
try:
|
||||
if self.debug:
|
||||
print(f"[DEBUG] {item_date} [{station}] → DB 저장 시도: {data}")
|
||||
print(f"[DEBUG] {item_date} [{station}] DB 저장 시도: {data}")
|
||||
continue
|
||||
|
||||
if self.force_update:
|
||||
@ -75,6 +98,7 @@ class AirQualityCollector:
|
||||
print(f"[ERROR] DB 중복 오류: {e}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] DB 저장 실패: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
def fetch_air_quality_data_range(self, start_date_str, end_date_str, station_name, num_of_rows=100, page_no=1):
|
||||
url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
|
||||
@ -88,23 +112,39 @@ class AirQualityCollector:
|
||||
'msrstnName': station_name,
|
||||
}
|
||||
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=10)
|
||||
resp = self.session.get(url, params=params, timeout=20)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get('response', {}).get('body', {}).get('items', [])
|
||||
except requests.RequestException as e:
|
||||
print(f"[ERROR] 요청 실패: {e}")
|
||||
return []
|
||||
except ValueError as e:
|
||||
print(f"[ERROR] JSON 파싱 실패: {e}")
|
||||
except Exception as e:
|
||||
body_preview = None
|
||||
try:
|
||||
if resp is not None:
|
||||
body_preview = resp.text[:1000]
|
||||
except Exception:
|
||||
body_preview = None
|
||||
print(f"[ERROR] 요청 실패: {e} status={getattr(resp, 'status_code', 'n/a')} body_preview={body_preview}")
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def test_num_of_rows(self, station_name, date_str):
|
||||
# 캐시 확인
|
||||
try:
|
||||
cache_key = f"{station_name}:{date_str}"
|
||||
if cache_key in self._num_rows_cache:
|
||||
val = int(self._num_rows_cache[cache_key])
|
||||
print(f"[INFO] 캐시된 numOfRows 사용: {val} ({cache_key})")
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
max_rows = 1000
|
||||
min_rows = 100
|
||||
|
||||
while max_rows >= min_rows:
|
||||
resp = None
|
||||
try:
|
||||
url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
|
||||
params = {
|
||||
@ -116,13 +156,25 @@ class AirQualityCollector:
|
||||
'inqEndDt': date_str,
|
||||
'msrstnName': station_name,
|
||||
}
|
||||
resp = requests.get(url, params=params, timeout=10)
|
||||
resp = self.session.get(url, params=params, timeout=20)
|
||||
resp.raise_for_status()
|
||||
resp.json()
|
||||
resp.json() # 성공하면 해당 max_rows 사용 가능
|
||||
print(f"[INFO] numOfRows 최대값 탐색 성공: {max_rows}")
|
||||
# 캐시에 저장
|
||||
try:
|
||||
self._num_rows_cache[f"{station_name}:{date_str}"] = max_rows
|
||||
self._save_num_rows_cache()
|
||||
except Exception:
|
||||
pass
|
||||
return max_rows
|
||||
except Exception as e:
|
||||
print(f"[WARN] numOfRows={max_rows} 실패: {e}, 100 감소 후 재시도")
|
||||
body_preview = None
|
||||
try:
|
||||
if resp is not None:
|
||||
body_preview = resp.text[:500]
|
||||
except Exception:
|
||||
body_preview = None
|
||||
print(f"[WARN] numOfRows={max_rows} 실패: {e}, body_preview={body_preview} 100 감소 후 재시도")
|
||||
max_rows -= 100
|
||||
|
||||
print("[WARN] 최소 numOfRows 값(100)도 실패했습니다. 기본값 100 사용")
|
||||
@ -160,7 +212,7 @@ class AirQualityCollector:
|
||||
current_start = current_end + timedelta(days=1)
|
||||
|
||||
print("\n[INFO] 모든 측정소 데이터 처리 완료")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
config = db.load_config()
|
||||
collector = AirQualityCollector(config, db.engine, db_schema.air)
|
||||
|
||||
133
lib/common.py
133
lib/common.py
@ -1,31 +1,136 @@
|
||||
# common.py
|
||||
import os, yaml
|
||||
import os
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
import glob
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
def load_config():
|
||||
# 로거 설정
|
||||
def setup_logging(name: str, level: str = 'INFO') -> logging.Logger:
|
||||
"""
|
||||
conf/config.yaml 파일을 UTF-8로 읽어 파이썬 dict로 반환
|
||||
로거 설정 (일관된 포맷 적용)
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
level: 로그 레벨 (INFO, DEBUG, WARNING, ERROR)
|
||||
|
||||
Returns:
|
||||
Logger 인스턴스
|
||||
"""
|
||||
path = os.path.join(os.path.dirname(__file__), '..', 'conf', 'config.yaml')
|
||||
with open(path, encoding='utf-8') as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def get_logger(name):
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s')
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(name)s - %(levelname)s: %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
return logger
|
||||
|
||||
def wait_download_complete(download_dir, ext, timeout=60):
|
||||
for _ in range(timeout):
|
||||
files = glob.glob(os.path.join(download_dir, f"*.{ext.strip('.')}"))
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""기존 호환성 유지"""
|
||||
return setup_logging(name)
|
||||
|
||||
def load_config(config_path: str = None) -> dict:
|
||||
"""
|
||||
conf/config.yaml 파일을 UTF-8로 읽어 파이썬 dict로 반환
|
||||
|
||||
Args:
|
||||
config_path: 설정 파일 경로 (없으면 기본값 사용)
|
||||
|
||||
Returns:
|
||||
설정 딕셔너리
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 설정 파일을 찾을 수 없을 때
|
||||
yaml.YAMLError: YAML 파싱 실패 시
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = os.path.join(os.path.dirname(__file__), '..', 'conf', 'config.yaml')
|
||||
|
||||
try:
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f)
|
||||
if config is None:
|
||||
raise ValueError(f"설정 파일이 비어있음: {config_path}")
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"설정 파일을 찾을 수 없음: {config_path}")
|
||||
except yaml.YAMLError as e:
|
||||
raise yaml.YAMLError(f"YAML 파싱 오류: {e}")
|
||||
|
||||
def retry_on_exception(max_retries: int = 3, delay: float = 1.0, backoff: float = 2.0):
|
||||
"""
|
||||
지정된 횟수만큼 재시도하는 데코레이터
|
||||
|
||||
Args:
|
||||
max_retries: 최대 재시도 횟수
|
||||
delay: 재시도 간격 (초)
|
||||
backoff: 재시도마다 지연 시간 배수
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Any:
|
||||
logger = logging.getLogger(func.__module__)
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception 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 wait_download_complete(download_dir: str, ext: str, timeout: int = 60) -> str:
|
||||
"""
|
||||
파일 다운로드 완료 대기
|
||||
|
||||
Args:
|
||||
download_dir: 다운로드 디렉토리
|
||||
ext: 파일 확장자 (예: 'xlsx', 'csv')
|
||||
timeout: 대기 시간 (초)
|
||||
|
||||
Returns:
|
||||
다운로드된 파일 경로
|
||||
|
||||
Raises:
|
||||
TimeoutError: 지정 시간 내 파일이 나타나지 않을 때
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
ext = ext.lstrip('.')
|
||||
|
||||
for i in range(timeout):
|
||||
files = glob.glob(os.path.join(download_dir, f"*.{ext}"))
|
||||
if files:
|
||||
logger.info(f"다운로드 완료: {files[0]}")
|
||||
return files[0]
|
||||
|
||||
if i > 0 and i % 10 == 0:
|
||||
logger.debug(f"다운로드 대기 중... ({i}초 경과)")
|
||||
|
||||
time.sleep(1)
|
||||
raise TimeoutError("다운로드 대기 시간 초과")
|
||||
|
||||
raise TimeoutError(
|
||||
f"파일 다운로드 대기 시간 초과 ({timeout}초): {download_dir}/*.{ext}"
|
||||
)
|
||||
90
lib/ga4.py
90
lib/ga4.py
@ -1,7 +1,7 @@
|
||||
# ga4.py
|
||||
# ga4.py
|
||||
'''
|
||||
퍼스트가든 구글 애널리틱스 API를 활용해 관련 데이터를 DB에 저장함
|
||||
병렬 처리를 통해 처리 속도 향상
|
||||
병렬 처리를 통해 처리 속도 향상 (내부 병렬은 유지하되 에러/재시도 보강)
|
||||
'''
|
||||
|
||||
import sys, os
|
||||
@ -9,6 +9,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
import yaml
|
||||
import pprint
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.parser import parse
|
||||
from google.analytics.data import BetaAnalyticsDataClient
|
||||
@ -38,25 +39,33 @@ def load_config():
|
||||
# GA4 클라이언트 초기화
|
||||
# ------------------------
|
||||
def init_ga_client(service_account_file):
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = service_account_file
|
||||
print(f"[INFO] GA4 클라이언트 초기화 - 인증파일: {service_account_file}")
|
||||
return BetaAnalyticsDataClient()
|
||||
try:
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = service_account_file
|
||||
print(f"[INFO] GA4 클라이언트 초기화 - 인증파일: {service_account_file}")
|
||||
return BetaAnalyticsDataClient()
|
||||
except Exception as e:
|
||||
print(f"[ERROR] GA4 클라이언트 초기화 실패: {e}")
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
# ------------------------
|
||||
# config.yaml에 최대 rows 저장
|
||||
# ------------------------
|
||||
def update_config_file_with_max_rows(max_rows):
|
||||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f)
|
||||
try:
|
||||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
if "ga4" not in config:
|
||||
config["ga4"] = {}
|
||||
config["ga4"]["max_rows_per_request"] = max_rows
|
||||
if "ga4" not in config:
|
||||
config["ga4"] = {}
|
||||
config["ga4"]["max_rows_per_request"] = int(max_rows)
|
||||
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config, f, allow_unicode=True)
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config, f, allow_unicode=True)
|
||||
|
||||
print(f"[INFO] config.yaml에 max_rows_per_request = {max_rows} 저장 완료")
|
||||
print(f"[INFO] config.yaml에 max_rows_per_request = {max_rows} 저장 완료")
|
||||
except Exception as e:
|
||||
print(f"[WARN] config.yaml 업데이트 실패: {e}")
|
||||
|
||||
# ------------------------
|
||||
# GA4 API로 최대 rows 감지
|
||||
@ -71,10 +80,13 @@ def detect_max_rows_supported(client, property_id):
|
||||
limit=100000
|
||||
)
|
||||
response = client.run_report(request)
|
||||
print(f"[INFO] 최대 rows 감지: {len(response.rows)} rows 수신됨.")
|
||||
return len(response.rows)
|
||||
nrows = len(response.rows)
|
||||
print(f"[INFO] 최대 rows 감지: {nrows} rows 수신됨.")
|
||||
return nrows
|
||||
except Exception as e:
|
||||
print(f"[WARNING] 최대 rows 감지 실패: {e}")
|
||||
traceback.print_exc()
|
||||
# 안전한 기본값 반환
|
||||
return 10000
|
||||
|
||||
# ------------------------
|
||||
@ -82,21 +94,31 @@ def detect_max_rows_supported(client, property_id):
|
||||
# ------------------------
|
||||
def fetch_report(client, property_id, start_date, end_date, dimensions, metrics, limit=10000):
|
||||
print(f"[INFO] fetch_report 호출 - 기간: {start_date} ~ {end_date}, dims={dimensions}, metrics={metrics}")
|
||||
request = RunReportRequest(
|
||||
property=f"properties/{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 = client.run_report(request)
|
||||
print(f"[INFO] GA4 리포트 응답 받음: {len(response.rows)} rows")
|
||||
return response
|
||||
try:
|
||||
request = RunReportRequest(
|
||||
property=f"properties/{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 = client.run_report(request)
|
||||
print(f"[INFO] GA4 리포트 응답 받음: {len(response.rows)} rows")
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"[ERROR] GA4 fetch_report 실패: {e}")
|
||||
traceback.print_exc()
|
||||
# 빈 응답 형태 반환하는 대신 None 반환해서 호출부가 처리하도록 함
|
||||
return None
|
||||
|
||||
# ------------------------
|
||||
# 응답 데이터를 DB에 저장
|
||||
# ------------------------
|
||||
def save_report_to_db(engine, table, response, dimension_names, metric_names, debug=False):
|
||||
if response is None:
|
||||
print("[INFO] 저장할 응답 없음 (None)")
|
||||
return
|
||||
|
||||
with engine.begin() as conn:
|
||||
for row in response.rows:
|
||||
dims = row.dimension_values
|
||||
@ -137,6 +159,7 @@ def save_report_to_db(engine, table, response, dimension_names, metric_names, de
|
||||
print(f"[DB ERROR] 중복 오류 또는 기타: {e}")
|
||||
except Exception as e:
|
||||
print(f"[DB ERROR] 저장 실패: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# ------------------------
|
||||
# 테이블에서 마지막 날짜 조회
|
||||
@ -175,7 +198,6 @@ def determine_date_range(table, config_start, config_end, force_update, engine):
|
||||
else:
|
||||
actual_start = config_start
|
||||
|
||||
# 시작일이 종료일보다 뒤에 있으면 자동 교체
|
||||
if actual_start > actual_end:
|
||||
print(f"[WARN] 시작일({actual_start})이 종료일({actual_end})보다 뒤에 있습니다. 날짜를 교환하여 수집을 계속합니다.")
|
||||
actual_start, actual_end = actual_end, actual_start
|
||||
@ -201,10 +223,10 @@ def process_dimension_metric(engine, client, property_id, config, table, dims, m
|
||||
end_str = end_dt.strftime('%Y-%m-%d')
|
||||
print(f"[INFO] GA4 데이터 조회: {start_str} ~ {end_str}")
|
||||
response = fetch_report(client, property_id, start_str, end_str, dimensions=dims, metrics=mets, limit=max_rows)
|
||||
if len(response.rows) > 0:
|
||||
if response and len(response.rows) > 0:
|
||||
save_report_to_db(engine, table, response, dimension_names=dims, metric_names=mets, debug=debug)
|
||||
else:
|
||||
print(f"[INFO] 해당 기간 {start_str} ~ {end_str} 데이터 없음")
|
||||
print(f"[INFO] 해당 기간 {start_str} ~ {end_str} 데이터 없음 또는 요청 실패")
|
||||
|
||||
# ------------------------
|
||||
# 메인 진입점 (병렬 처리 포함)
|
||||
@ -224,12 +246,19 @@ def main():
|
||||
return
|
||||
|
||||
engine = db.engine
|
||||
client = init_ga_client(service_account_file)
|
||||
try:
|
||||
client = init_ga_client(service_account_file)
|
||||
except Exception:
|
||||
print("[ERROR] GA4 클라이언트 초기화 실패로 종료합니다.")
|
||||
return
|
||||
|
||||
max_rows = ga4_cfg.get("max_rows_per_request")
|
||||
if not max_rows:
|
||||
max_rows = detect_max_rows_supported(client, property_id)
|
||||
update_config_file_with_max_rows(max_rows)
|
||||
try:
|
||||
update_config_file_with_max_rows(max_rows)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[INFO] 설정된 max_rows_per_request = {max_rows}")
|
||||
|
||||
tasks = [
|
||||
@ -253,6 +282,7 @@ def main():
|
||||
print(f"[INFO] 태스크 {i} 완료")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 태스크 {i} 실패: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
print("[INFO] GA4 데이터 수집 및 저장 완료")
|
||||
|
||||
|
||||
@ -18,6 +18,14 @@ CONFIG = load_config()
|
||||
DATA_DIR = os.path.join(os.path.dirname(__file__), '../data')
|
||||
|
||||
def update_pos_table(engine, table, df):
|
||||
"""
|
||||
데이터프레임을 테이블에 업데이트
|
||||
|
||||
Args:
|
||||
engine: SQLAlchemy 엔진
|
||||
table: DB 테이블 객체
|
||||
df: 데이터프레임
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
for idx, row in df.iterrows():
|
||||
data = row.to_dict()
|
||||
@ -39,6 +47,17 @@ def update_pos_table(engine, table, df):
|
||||
print("[DONE] 모든 데이터 삽입 완료")
|
||||
|
||||
def process_file(filepath, table, engine):
|
||||
"""
|
||||
OKPOS 파일 처리
|
||||
|
||||
Args:
|
||||
filepath: 파일 경로
|
||||
table: DB 테이블
|
||||
engine: SQLAlchemy 엔진
|
||||
|
||||
Returns:
|
||||
tuple[bool, int]: (성공 여부, 행 수)
|
||||
"""
|
||||
print(f"[INFO] 처리 시작: {filepath}")
|
||||
try:
|
||||
ext = os.path.splitext(filepath)[-1].lower()
|
||||
@ -86,6 +105,51 @@ def process_file(filepath, table, engine):
|
||||
print(f"[INFO] 처리 완료: {filepath}")
|
||||
return True, len(df)
|
||||
|
||||
|
||||
def process_okpos_file(filepath):
|
||||
"""
|
||||
OKPOS 파일을 처리하고 DB에 저장
|
||||
웹 업로드 인터페이스에서 사용하는 함수
|
||||
|
||||
Args:
|
||||
filepath (str): 업로드된 파일 경로
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'success': bool,
|
||||
'message': str,
|
||||
'rows_inserted': int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
engine = db.engine
|
||||
table = db_schema.pos
|
||||
|
||||
# 파일 처리
|
||||
success, row_count = process_file(filepath, table, engine)
|
||||
|
||||
if success:
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'{row_count}행이 저장되었습니다.',
|
||||
'rows_inserted': row_count
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'message': '파일 처리에 실패했습니다.',
|
||||
'rows_inserted': 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] OKPOS 파일 처리 오류: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'파일 처리 중 오류: {str(e)}',
|
||||
'rows_inserted': 0
|
||||
}
|
||||
|
||||
|
||||
def batch_process_files(table, engine):
|
||||
files = [f for f in os.listdir(DATA_DIR) if f.startswith("일자별 (상품별)") and f.endswith(('.xlsx', '.xls'))]
|
||||
if not files:
|
||||
|
||||
@ -71,9 +71,21 @@ def prepare_bulk_data(df):
|
||||
|
||||
|
||||
def process_bulk_upsert(bulk_data, session, table, batch_size=1000):
|
||||
"""
|
||||
데이터 일괄 삽입/업데이트
|
||||
|
||||
Args:
|
||||
bulk_data: 삽입할 데이터 리스트
|
||||
session: DB 세션
|
||||
table: 대상 테이블
|
||||
batch_size: 배치 크기
|
||||
|
||||
Returns:
|
||||
int: 삽입된 행 수
|
||||
"""
|
||||
if not bulk_data:
|
||||
logger.info("[INFO] 삽입할 데이터가 없습니다.")
|
||||
return
|
||||
return 0
|
||||
|
||||
total = len(bulk_data)
|
||||
inserted_total = 0
|
||||
@ -96,8 +108,11 @@ def process_bulk_upsert(bulk_data, session, table, batch_size=1000):
|
||||
raise
|
||||
|
||||
logger.info(f"[DONE] 총 {total}건 처리 완료 (insert+update)")
|
||||
return inserted_total
|
||||
|
||||
|
||||
def file_reader(queue, files):
|
||||
"""파일 읽기 스레드"""
|
||||
for filepath in files:
|
||||
try:
|
||||
logger.info(f"[READ] {os.path.basename(filepath)} 읽기 시작")
|
||||
@ -111,6 +126,7 @@ def file_reader(queue, files):
|
||||
|
||||
|
||||
def db_writer(queue, session, table):
|
||||
"""DB 쓰기 스레드"""
|
||||
while True:
|
||||
item = queue.get()
|
||||
if item is None:
|
||||
@ -126,6 +142,66 @@ def db_writer(queue, session, table):
|
||||
logger.error(f"[FAIL] {os.path.basename(filepath)} DB 삽입 실패 - {e}")
|
||||
|
||||
|
||||
def process_upsolution_file(filepath):
|
||||
"""
|
||||
UPSOLUTION 파일을 처리하고 DB에 저장
|
||||
웹 업로드 인터페이스에서 사용하는 함수
|
||||
|
||||
Args:
|
||||
filepath (str): 업로드된 파일 경로
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'success': bool,
|
||||
'message': str,
|
||||
'rows_inserted': int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[WEB] UPSOLUTION 파일 처리 시작: {filepath}")
|
||||
|
||||
# 파일 읽기
|
||||
df = load_excel_data(filepath)
|
||||
logger.info(f"[WEB] 데이터 읽기 완료: {len(df)}행")
|
||||
|
||||
# 데이터 준비
|
||||
bulk_data = prepare_bulk_data(df)
|
||||
logger.info(f"[WEB] 데이터 준비 완료: {len(bulk_data)}행")
|
||||
|
||||
# DB 세션 및 테이블 가져오기
|
||||
session = db.get_session()
|
||||
engine = db.get_engine()
|
||||
|
||||
metadata = MetaData()
|
||||
table = Table(
|
||||
db_schema.get_full_table_name("pos_ups_billdata"),
|
||||
metadata,
|
||||
autoload_with=engine
|
||||
)
|
||||
|
||||
# 데이터 삽입
|
||||
inserted = process_bulk_upsert(bulk_data, session, table)
|
||||
|
||||
session.close()
|
||||
|
||||
logger.info(f"[WEB] UPSOLUTION 파일 처리 완료: {inserted}행 삽입")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'{inserted}행이 저장되었습니다.',
|
||||
'rows_inserted': inserted
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[WEB] UPSOLUTION 파일 처리 오류: {e}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'파일 처리 중 오류: {str(e)}',
|
||||
'rows_inserted': 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
engine = db.get_engine()
|
||||
session = db.get_session()
|
||||
@ -142,7 +218,7 @@ def main():
|
||||
|
||||
logger.info(f"[INFO] 처리할 파일 {len(files)}개")
|
||||
|
||||
queue = Queue(maxsize=2) # 2개 정도 여유 있게
|
||||
queue = Queue(maxsize=3) # 2개 정도 여유 있게
|
||||
|
||||
reader_thread = threading.Thread(target=file_reader, args=(queue, files))
|
||||
writer_thread = threading.Thread(target=db_writer, args=(queue, session, table))
|
||||
|
||||
21
lib/requests_utils.py
Normal file
21
lib/requests_utils.py
Normal file
@ -0,0 +1,21 @@
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
def make_requests_session(retries=3, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504)):
|
||||
"""
|
||||
재시도(backoff)를 적용한 requests.Session 반환
|
||||
"""
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=retries,
|
||||
read=retries,
|
||||
connect=retries,
|
||||
backoff_factor=backoff_factor,
|
||||
status_forcelist=status_forcelist,
|
||||
allowed_methods=frozenset(["HEAD", "GET", "OPTIONS", "POST"])
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
return session
|
||||
44
lib/to_csv.py
Normal file
44
lib/to_csv.py
Normal file
@ -0,0 +1,44 @@
|
||||
import os, sys
|
||||
import shutil
|
||||
import pandas as pd
|
||||
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from lib.common import get_logger
|
||||
|
||||
logger = get_logger("TO_CSV")
|
||||
|
||||
DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
|
||||
FINISH_DIR = os.path.join(DATA_DIR, "finish")
|
||||
os.makedirs(FINISH_DIR, exist_ok=True)
|
||||
|
||||
def convert_excel_to_csv(filepath):
|
||||
try:
|
||||
logger.info(f"변환 시작: {os.path.basename(filepath)}")
|
||||
df = pd.read_excel(filepath, header=1) # 2행이 헤더
|
||||
df.columns = [col.strip() for col in df.columns]
|
||||
|
||||
csv_path = filepath + '.csv'
|
||||
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
|
||||
logger.info(f"변환 완료: {os.path.basename(csv_path)}")
|
||||
|
||||
# 변환 완료된 원본 엑셀 파일 이동
|
||||
dest_path = os.path.join(FINISH_DIR, os.path.basename(filepath))
|
||||
shutil.move(filepath, dest_path)
|
||||
logger.info(f"원본 엑셀 파일 이동 완료: {os.path.basename(dest_path)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"변환 실패: {os.path.basename(filepath)} - {e}")
|
||||
|
||||
def main():
|
||||
files = [os.path.join(DATA_DIR, f) for f in os.listdir(DATA_DIR)
|
||||
if (f.endswith(('.xls', '.xlsx')) and f.startswith("영수증별 상세매출"))]
|
||||
|
||||
logger.info(f"총 {len(files)}개 엑셀 파일 변환 시작")
|
||||
|
||||
for filepath in files:
|
||||
convert_excel_to_csv(filepath)
|
||||
|
||||
logger.info("모든 파일 변환 완료")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,4 +1,4 @@
|
||||
import sys, os
|
||||
import sys, os
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
import yaml
|
||||
@ -6,8 +6,10 @@ import requests
|
||||
from datetime import datetime, timedelta, date
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy import select
|
||||
import traceback
|
||||
|
||||
from conf import db, db_schema
|
||||
from requests_utils import make_requests_session
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "../conf/config.yaml")
|
||||
|
||||
@ -24,7 +26,9 @@ def fetch_data_range_chunks(start_dt, end_dt, chunk_days=10):
|
||||
yield current_start.strftime("%Y%m%d"), current_end.strftime("%Y%m%d")
|
||||
current_start = current_end + timedelta(days=1)
|
||||
|
||||
def fetch_asos_data(stn_id, start_dt, end_dt, service_key):
|
||||
def fetch_asos_data(stn_id, start_dt, end_dt, service_key, session=None):
|
||||
if session is None:
|
||||
session = make_requests_session()
|
||||
url = "http://apis.data.go.kr/1360000/AsosDalyInfoService/getWthrDataList"
|
||||
|
||||
params = {
|
||||
@ -44,8 +48,9 @@ def fetch_asos_data(stn_id, start_dt, end_dt, service_key):
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=15)
|
||||
resp = session.get(url, params=params, headers=headers, timeout=20)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("response", {}).get("body", {}).get("items", {}).get("item", [])
|
||||
@ -57,7 +62,14 @@ def fetch_asos_data(stn_id, start_dt, end_dt, service_key):
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] API 요청 실패: {e}")
|
||||
body_preview = None
|
||||
try:
|
||||
if resp is not None:
|
||||
body_preview = resp.text[:1000]
|
||||
except Exception:
|
||||
body_preview = None
|
||||
print(f"[ERROR] ASOS API 요청 실패: {e} status={getattr(resp, 'status_code', 'n/a')} body_preview={body_preview}")
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def save_items_to_db(items, conn, table, force_update=False, debug=False):
|
||||
@ -98,7 +110,7 @@ def save_items_to_db(items, conn, table, force_update=False, debug=False):
|
||||
data[key] = None
|
||||
|
||||
if debug:
|
||||
print(f"[DEBUG] {record_date} → DB 저장 시도: {data}")
|
||||
print(f"[DEBUG] {record_date} DB 저장 시도: {data}")
|
||||
continue
|
||||
|
||||
if force_update:
|
||||
@ -116,6 +128,7 @@ def save_items_to_db(items, conn, table, force_update=False, debug=False):
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 저장 실패: {e}")
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
def get_latest_date_from_db(conn, table):
|
||||
@ -149,6 +162,8 @@ def main():
|
||||
|
||||
chunk_days = 1000
|
||||
|
||||
session = make_requests_session()
|
||||
|
||||
with engine.begin() as conn:
|
||||
print(f"[INFO] DB 저장 최종 일자 점검")
|
||||
latest_date = get_latest_date_from_db(conn, table)
|
||||
@ -170,7 +185,7 @@ def main():
|
||||
for stn_id in stn_ids:
|
||||
for chunk_start, chunk_end in fetch_data_range_chunks(start_dt, end_dt, chunk_days):
|
||||
print(f"[INFO] 지점 {stn_id} 데이터 요청 중: {chunk_start} ~ {chunk_end}")
|
||||
items = fetch_asos_data(stn_id, chunk_start, chunk_end, service_key)
|
||||
items = fetch_asos_data(stn_id, chunk_start, chunk_end, service_key, session=session)
|
||||
if items:
|
||||
save_items_to_db(items, conn, table, force_update, debug)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user