From d8c7f6f19ccf20a3fd60e97a3738ca00685a56a9 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 13 May 2026 12:19:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=AB=E5=8F=96=E7=95=B6=E6=97=A5=E6=A5=AD?= =?UTF-8?q?=E7=B8=BE=E9=A0=81=E9=9D=A2=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/daily_sales_routes.py | 51 ++++++++++++++++++++++++++++++++++-- services/cache_manager.py | 10 +++++++ tests/test_cache_manager.py | 25 ++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/routes/daily_sales_routes.py b/routes/daily_sales_routes.py index 40ba7d2..3025bd2 100644 --- a/routes/daily_sales_routes.py +++ b/routes/daily_sales_routes.py @@ -7,6 +7,9 @@ import io import calendar +import hashlib +import os +import pickle from datetime import datetime, timezone, timedelta from urllib.parse import quote from flask import Blueprint, request, render_template, send_file @@ -14,7 +17,7 @@ from auth import login_required from sqlalchemy import inspect, text import pandas as pd -from config import BASE_DIR +from config import BASE_DIR, SYSTEM_VERSION from database.manager import DatabaseManager from services.logger_manager import SystemLogger from utils.df_helpers import find_col @@ -25,6 +28,7 @@ from services.daily_sales_service import ( ) from services.cache_manager import ( _DAILY_SALES_PROCESSED_CACHE as _SALES_PROCESSED_CACHE, + _DAILY_SALES_VIEW_CACHE_DIR, clear_daily_sales_cache as _clear_daily_sales_cache, ) @@ -39,6 +43,7 @@ daily_sales_bp = Blueprint('daily_sales', __name__) _CACHE_EXPIRY_SECONDS = 300 # 5 分鐘緩存過期 _VIEW_CACHE_EXPIRY_SECONDS = 120 +_SHARED_VIEW_CACHE_EXPIRY_SECONDS = 1800 _DAILY_SALES_VIEW_CACHE = {} _DAILY_SALES_VIEW_CACHE_MAX = 24 @@ -67,6 +72,46 @@ def _set_daily_view_cache(cache_key, context): } +def _daily_view_cache_file(cache_key): + digest = hashlib.md5(cache_key.encode('utf-8'), usedforsecurity=False).hexdigest() + return _DAILY_SALES_VIEW_CACHE_DIR / f"{digest}.pkl" + + +def _get_shared_daily_view_cache(cache_key): + path = _daily_view_cache_file(cache_key) + if not path.exists(): + return None + try: + if (datetime.now().timestamp() - path.stat().st_mtime) >= _SHARED_VIEW_CACHE_EXPIRY_SECONDS: + path.unlink(missing_ok=True) + return None + with open(path, 'rb') as f: + payload = pickle.load(f) + if payload.get('version') != SYSTEM_VERSION: + return None + return payload.get('context') + except Exception as exc: + sys_log.warning(f"[DailySales] 共享 view cache 讀取失敗: {exc}") + return None + + +def _set_shared_daily_view_cache(cache_key, context): + path = _daily_view_cache_file(cache_key) + tmp_path = path.with_suffix(f".{os.getpid()}.tmp") + try: + os.makedirs(path.parent, exist_ok=True) + with open(tmp_path, 'wb') as f: + pickle.dump({'version': SYSTEM_VERSION, 'context': context}, f, protocol=pickle.HIGHEST_PROTOCOL) + os.replace(tmp_path, path) + except Exception as exc: + sys_log.warning(f"[DailySales] 共享 view cache 寫入失敗: {exc}") + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass + + def clear_daily_sales_cache(): """清除當日業績緩存(供匯入服務調用)""" _clear_daily_sales_cache() @@ -488,8 +533,9 @@ def daily_sales(): 'month' if is_month_view else 'day', str(current_fingerprint), ]) - cached_context = _get_daily_view_cache(view_cache_key) + cached_context = _get_daily_view_cache(view_cache_key) or _get_shared_daily_view_cache(view_cache_key) if cached_context: + _set_daily_view_cache(view_cache_key, cached_context) return render_template('daily_sales.html', **cached_context) cache_key = f"{table_name}_daily_{data_start.strftime('%Y%m%d')}_{data_end.strftime('%Y%m%d')}" @@ -581,6 +627,7 @@ def daily_sales(): 'active_page': 'daily_sales', } _set_daily_view_cache(view_cache_key, context) + _set_shared_daily_view_cache(view_cache_key, context) return render_template('daily_sales.html', **context) except Exception as e: diff --git a/services/cache_manager.py b/services/cache_manager.py index 0a28901..b0e2acb 100644 --- a/services/cache_manager.py +++ b/services/cache_manager.py @@ -70,6 +70,7 @@ _BASE_DIR = Path(__file__).resolve().parents[1] _DASHBOARD_SHARED_CACHE_FILE = _BASE_DIR / "data" / "dashboard_full_cache.pkl" _DASHBOARD_STALE_CACHE_FILE = _BASE_DIR / "data" / "dashboard_full_cache_stale.pkl" _SALES_ANALYSIS_PAGE_CACHE_DIR = _BASE_DIR / "data" / "sales_analysis_page_cache" +_DAILY_SALES_VIEW_CACHE_DIR = _BASE_DIR / "data" / "daily_sales_view_cache" def cleanup_sales_cache(): @@ -131,6 +132,15 @@ def clear_sales_cache(): def clear_daily_sales_cache(): """清除當日業績 cache。""" _DAILY_SALES_PROCESSED_CACHE.clear() + try: + if _DAILY_SALES_VIEW_CACHE_DIR.exists(): + for cache_file in _DAILY_SALES_VIEW_CACHE_DIR.glob("*.pkl"): + try: + cache_file.unlink() + except OSError: + logger.debug("daily sales view cache file cleanup failed: %s", cache_file, exc_info=True) + except OSError: + logger.debug("daily sales view cache cleanup failed", exc_info=True) def clear_dashboard_cache(): diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index e0f4cb4..83bf708 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -119,6 +119,31 @@ def test_clear_sales_cache_removes_shared_page_cache_files(tmp_path, monkeypatch assert not cache_file.exists() +def test_daily_sales_shared_view_cache_roundtrip(tmp_path, monkeypatch): + from routes import daily_sales_routes + + monkeypatch.setattr(daily_sales_routes, "_DAILY_SALES_VIEW_CACHE_DIR", tmp_path) + cache_key = "daily_sales:view:test" + context = {"summary": {"revenue": 456}, "active_page": "daily_sales"} + + daily_sales_routes._set_shared_daily_view_cache(cache_key, context) + + assert daily_sales_routes._get_shared_daily_view_cache(cache_key) == context + + +def test_clear_daily_sales_cache_removes_shared_view_cache_files(tmp_path, monkeypatch): + from services import cache_manager + + monkeypatch.setattr(cache_manager, "_DAILY_SALES_VIEW_CACHE_DIR", tmp_path) + tmp_path.mkdir(parents=True, exist_ok=True) + cache_file = tmp_path / "daily_sales_view.pkl" + cache_file.write_bytes(b"stale") + + cache_manager.clear_daily_sales_cache() + + assert not cache_file.exists() + + def test_cache_dicts_are_only_defined_in_cache_manager(): assignments = [] for path in [ROOT / "app.py", *ROOT.glob("routes/*.py"), *ROOT.glob("services/*.py")]: