From 8d0c442bddffc17531ead922b4e3fc716a4a7cd0 Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 19 May 2026 12:44:17 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A9=A9=E5=AE=9A=E6=88=90=E9=95=B7=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=85=B1=E4=BA=AB=E5=BF=AB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- services/cache_service.py | 67 +++++++++++++++++++++++++++++++++---- tests/test_cache_manager.py | 37 ++++++++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 853335c..13c6746 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.251 穩定 `/growth_analysis` 正式站速度:成長分析快取從單 worker memory 擴充為 `data/growth_analysis_cache.pkl` 跨 worker 共享快取,避免 Gunicorn 冷 worker 偶發掃明細表造成 5 秒級 TTFB;補 `tests/test_cache_manager.py` 覆蓋 shared file roundtrip 與清除行為。 - V10.249 收斂 `/observability/ppt_audit_history` 手機與平板第一屏密度:將 4 個產線訊號從 hero 內移出成獨立狀態列,手機版維持 2 欄狀態卡並降低 hero 卡片間距;本機 10 個 AI 觀測台頁面 rendered visual contract 全數通過,PPT 頁 hero 高度 desktop/tablet/mobile 為 214/361/398px。 - V10.246 強化 `/observability/ppt_audit_history` 視覺 QA runtime 可讀性:功能開關、轉檔器與視覺模型改成中文 checklist,Vision QA 狀態卡直接顯示 runtime 就緒資訊,DB 產出狀態統一為「已產出」。 - V10.245 重整 `/observability/ppt_audit_history` 首屏資訊階層:改成簡報操作摘要、最新可預覽簡報、下一步動作與自適應報表類型 segmented grid;產線覆蓋矩陣改為下方驗收明細,避免一進頁只看到大量「產線狀態」或類型按鈕右側溢出。 diff --git a/config.py b/config.py index 15b85bf..ad501b8 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.250" +SYSTEM_VERSION = "V10.251" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/cache_service.py b/services/cache_service.py index cdcbdde..040b510 100644 --- a/services/cache_service.py +++ b/services/cache_service.py @@ -7,7 +7,10 @@ Sales/dashboard cache state lives in services.cache_manager. This module keeps older imports working and only owns growth-analysis and slow-query helpers. """ +import os +import pickle from datetime import datetime, timezone, timedelta +from pathlib import Path from services.cache_manager import ( _SALES_DF_CACHE, _SALES_PROCESSED_CACHE, @@ -37,6 +40,8 @@ _GROWTH_ANALYSIS_CACHE = { 'source_fingerprint': None, } _GROWTH_CACHE_TTL = 1800 # 成長分析快取: 30 分鐘 +_BASE_DIR = Path(__file__).resolve().parents[1] +_GROWTH_SHARED_CACHE_FILE = _BASE_DIR / "data" / "growth_analysis_cache.pkl" # ========================================== # 慢查詢監控 @@ -84,6 +89,11 @@ def clear_growth_cache(): 'timestamp': None, 'source_fingerprint': None, } + try: + if os.path.exists(_GROWTH_SHARED_CACHE_FILE): + os.remove(_GROWTH_SHARED_CACHE_FILE) + except OSError: + pass def clear_all_cache(): @@ -118,21 +128,66 @@ def get_growth_cache(): return _GROWTH_ANALYSIS_CACHE +def _is_growth_payload_valid(payload, source_fingerprint=None): + if not payload: + return False + if not payload.get('chart_data') or not payload.get('kpi'): + return False + if not is_cache_valid(payload.get('timestamp'), _GROWTH_CACHE_TTL): + return False + if source_fingerprint is not None: + return payload.get('source_fingerprint') == source_fingerprint + return True + + +def _load_growth_cache_file(source_fingerprint=None): + """讀取跨 worker 成長分析快取,避免冷 worker 重新掃明細表。""" + global _GROWTH_ANALYSIS_CACHE + try: + if not os.path.exists(_GROWTH_SHARED_CACHE_FILE): + return False + with open(_GROWTH_SHARED_CACHE_FILE, 'rb') as f: + payload = pickle.load(f) + if not _is_growth_payload_valid(payload, source_fingerprint): + return False + _GROWTH_ANALYSIS_CACHE = payload + return True + except Exception: + return False + + +def _write_growth_cache_file(payload): + """原子寫入成長分析共享快取。""" + cache_file = str(_GROWTH_SHARED_CACHE_FILE) + tmp_file = f"{cache_file}.{os.getpid()}.tmp" + try: + os.makedirs(os.path.dirname(cache_file), exist_ok=True) + with open(tmp_file, 'wb') as f: + pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL) + os.replace(tmp_file, cache_file) + except Exception: + try: + if os.path.exists(tmp_file): + os.remove(tmp_file) + except OSError: + pass + + def set_growth_cache(chart_data, kpi, source_fingerprint=None): """設定成長分析快取""" global _GROWTH_ANALYSIS_CACHE - _GROWTH_ANALYSIS_CACHE = { + payload = { 'chart_data': chart_data, 'kpi': kpi, 'timestamp': datetime.now(TAIPEI_TZ), 'source_fingerprint': source_fingerprint, } + _GROWTH_ANALYSIS_CACHE = payload + _write_growth_cache_file(payload) def is_growth_cache_valid(source_fingerprint=None): """檢查成長分析快取是否有效""" - if not is_cache_valid(_GROWTH_ANALYSIS_CACHE.get('timestamp'), _GROWTH_CACHE_TTL): - return False - if source_fingerprint is not None: - return _GROWTH_ANALYSIS_CACHE.get('source_fingerprint') == source_fingerprint - return True + if _is_growth_payload_valid(_GROWTH_ANALYSIS_CACHE, source_fingerprint): + return True + return _load_growth_cache_file(source_fingerprint) diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 425df33..177a62d 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -120,6 +120,43 @@ def test_clear_sales_cache_removes_shared_page_cache_files(tmp_path, monkeypatch assert not cache_file.exists() +def test_growth_cache_shared_file_roundtrip(tmp_path, monkeypatch): + from services import cache_service + + shared_cache = tmp_path / "growth_analysis_cache.pkl" + monkeypatch.setattr(cache_service, "_GROWTH_SHARED_CACHE_FILE", shared_cache) + + cache_service.clear_growth_cache() + cache_service.set_growth_cache( + {"labels": ["2026-05"], "revenue": [1000]}, + {"ytd_revenue": 1000}, + source_fingerprint=("2026-05-17", 10), + ) + + cache_service._GROWTH_ANALYSIS_CACHE = { + "chart_data": None, + "kpi": None, + "timestamp": None, + "source_fingerprint": None, + } + + assert cache_service.is_growth_cache_valid(("2026-05-17", 10)) + assert cache_service.get_growth_cache()["chart_data"]["revenue"] == [1000] + assert shared_cache.exists() + + +def test_clear_growth_cache_removes_shared_file(tmp_path, monkeypatch): + from services import cache_service + + shared_cache = tmp_path / "growth_analysis_cache.pkl" + shared_cache.write_bytes(b"stale") + monkeypatch.setattr(cache_service, "_GROWTH_SHARED_CACHE_FILE", shared_cache) + + cache_service.clear_growth_cache() + + assert not shared_cache.exists() + + def test_daily_sales_shared_view_cache_roundtrip(tmp_path, monkeypatch): from routes import daily_sales_routes