From 1d3da03eee919b0bb05c7fdf652ecdce909ec846 Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 19 May 2026 13:24:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E9=80=9F=E6=88=90=E9=95=B7=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=8C=87=E7=B4=8B=E5=BF=AB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/import_routes.py | 12 ++++++++++++ routes/sales_routes.py | 17 ++++++++++++++++- services/cache_service.py | 33 +++++++++++++++++++++++++++++++++ services/import_service.py | 10 +++++++--- tests/test_cache_manager.py | 16 ++++++++++++++++ 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/routes/import_routes.py b/routes/import_routes.py index 787e3e6..268ff13 100644 --- a/routes/import_routes.py +++ b/routes/import_routes.py @@ -228,6 +228,12 @@ def import_excel(): message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。' clear_sales_cache_for_table(table_name) + if table_name == 'realtime_sales_monthly': + try: + from services.cache_service import clear_growth_cache + clear_growth_cache() + except Exception as cache_error: + sys_log.warning(f"[Web] [Cache] 成長分析快取清除失敗: {cache_error}") sys_log.info(f"[Web] [Cache] 已清除業績分析快取: {table_name}") return jsonify({'status': 'success', 'message': message, 'rows': rows_imported, 'table': table_name}) @@ -241,6 +247,12 @@ def import_excel(): df.to_sql(table_name, con=engine, if_exists='replace', index=False) clear_sales_cache_for_table(table_name) + if table_name == 'realtime_sales_monthly': + try: + from services.cache_service import clear_growth_cache + clear_growth_cache() + except Exception as cache_error: + sys_log.warning(f"[Web] [Cache] 成長分析快取清除失敗: {cache_error}") sys_log.info(f"[Web] [Cache] 已清除業績分析快取: {table_name}") return jsonify({ diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 302b0bc..6f0d893 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -466,6 +466,18 @@ def _get_growth_source_fingerprint(engine, table_name='realtime_sales_monthly'): if not inspect(engine).has_table(table_name): return None table_name = validate_table_name(table_name) + if engine.dialect.name == 'postgresql': + from services.cache_service import ( + get_growth_source_fingerprint_cache, + set_growth_source_fingerprint_cache, + ) + cache_key = f"{engine.url}|{table_name}" + cached_fingerprint = get_growth_source_fingerprint_cache(cache_key) + if cached_fingerprint is not None: + return cached_fingerprint + else: + cache_key = None + table_ref = f'"{table_name}"' if engine.dialect.name == 'postgresql': fingerprint_sql = text(f""" @@ -481,7 +493,10 @@ def _get_growth_source_fingerprint(engine, table_name='realtime_sales_monthly'): """) with engine.connect() as conn: row = conn.execute(fingerprint_sql).fetchone() - return (row[0], row[1]) if row else None + fingerprint = (row[0], row[1]) if row else None + if cache_key is not None: + set_growth_source_fingerprint_cache(cache_key, fingerprint) + return fingerprint except Exception as exc: sys_log.warning(f"[GrowthAnalysis] 快取指紋查詢失敗,保守沿用 TTL: {exc}") return None diff --git a/services/cache_service.py b/services/cache_service.py index 040b510..f1fe02c 100644 --- a/services/cache_service.py +++ b/services/cache_service.py @@ -9,6 +9,7 @@ older imports working and only owns growth-analysis and slow-query helpers. import os import pickle +import time from datetime import datetime, timezone, timedelta from pathlib import Path from services.cache_manager import ( @@ -42,6 +43,8 @@ _GROWTH_ANALYSIS_CACHE = { _GROWTH_CACHE_TTL = 1800 # 成長分析快取: 30 分鐘 _BASE_DIR = Path(__file__).resolve().parents[1] _GROWTH_SHARED_CACHE_FILE = _BASE_DIR / "data" / "growth_analysis_cache.pkl" +_GROWTH_SOURCE_FINGERPRINT_CACHE = {} +_GROWTH_SOURCE_FINGERPRINT_TTL = 60 # ========================================== # 慢查詢監控 @@ -89,6 +92,7 @@ def clear_growth_cache(): 'timestamp': None, 'source_fingerprint': None, } + _GROWTH_SOURCE_FINGERPRINT_CACHE.clear() try: if os.path.exists(_GROWTH_SHARED_CACHE_FILE): os.remove(_GROWTH_SHARED_CACHE_FILE) @@ -128,6 +132,35 @@ def get_growth_cache(): return _GROWTH_ANALYSIS_CACHE +def get_growth_source_fingerprint_cache(cache_key): + """讀取短 TTL source fingerprint,避免每次命中頁面快取仍掃大表 COUNT。""" + entry = _GROWTH_SOURCE_FINGERPRINT_CACHE.get(cache_key) + if not entry: + return None + if time.time() - entry.get('time', 0) >= _GROWTH_SOURCE_FINGERPRINT_TTL: + _GROWTH_SOURCE_FINGERPRINT_CACHE.pop(cache_key, None) + return None + return entry.get('fingerprint') + + +def set_growth_source_fingerprint_cache(cache_key, fingerprint): + """寫入 source fingerprint 短快取;None 不快取,維持 fail-open。""" + if fingerprint is None: + return + _GROWTH_SOURCE_FINGERPRINT_CACHE[cache_key] = { + 'fingerprint': fingerprint, + 'time': time.time(), + } + + +def clear_growth_source_fingerprint_cache(cache_key=None): + """清除 source fingerprint 快取;匯入流程會呼叫 clear_growth_cache 一併處理。""" + if cache_key is None: + _GROWTH_SOURCE_FINGERPRINT_CACHE.clear() + else: + _GROWTH_SOURCE_FINGERPRINT_CACHE.pop(cache_key, None) + + def _is_growth_payload_valid(payload, source_fingerprint=None): if not payload: return False diff --git a/services/import_service.py b/services/import_service.py index 4640969..70ed926 100644 --- a/services/import_service.py +++ b/services/import_service.py @@ -701,9 +701,13 @@ class ImportService: session.close() logger.info(f"任務 {job_id} 匯入成功: {total_rows} 筆") - # cache 失效改靠 _get_data_fingerprint(DB max(snapshot_date)+count(*)), - # 寫入後指紋自動跳號,4 worker 下一次 request 時各自偵測失效, - # 取代不可靠的 N-POST hack(命中率僅 9.4%,見 web-researcher 報告)。 + try: + from services.cache_service import clear_growth_cache + clear_growth_cache() + except Exception as cache_error: + logger.warning(f"任務 {job_id} 成長分析快取清除失敗: {cache_error}") + # daily_sales cache 失效仍靠 _get_data_fingerprint(DB max(snapshot_date)+count(*)); + # growth cache 另有 shared file + source fingerprint,匯入後主動清掉避免短 TTL 內看到舊圖。 return True except Exception as e: diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 41ed560..23d1505 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -159,6 +159,22 @@ def test_clear_growth_cache_removes_shared_file(tmp_path, monkeypatch): assert not shared_cache.exists() +def test_growth_source_fingerprint_short_cache_clears_with_growth_cache(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_source_fingerprint_cache("growth:source", ("2026-05-17", 10)) + + assert cache_service.get_growth_source_fingerprint_cache("growth:source") == ("2026-05-17", 10) + + cache_service.clear_growth_cache() + + assert cache_service.get_growth_source_fingerprint_cache("growth:source") is None + + def test_daily_sales_shared_view_cache_roundtrip(tmp_path, monkeypatch): from routes import daily_sales_routes