fix(dashboard): prewarm cache and expose pick evidence
All checks were successful
CD Pipeline / deploy (push) Successful in 2m20s

This commit is contained in:
OoO
2026-05-01 16:34:13 +08:00
parent 9e2337764b
commit 2ac7410d40
10 changed files with 165 additions and 33 deletions

View File

@@ -25,6 +25,7 @@ from services.cache_manager import (
_DASHBOARD_DATA_CACHE,
_DASHBOARD_CACHE_TTL,
_DASHBOARD_SHARED_CACHE_FILE,
_DASHBOARD_STALE_CACHE_FILE,
)
# 時區設定
@@ -518,12 +519,16 @@ class FileLock:
self.fd = None
_DASHBOARD_FILE_LOCK = FileLock(_DASHBOARD_LOCK_FILE)
_DASHBOARD_STALE_CACHE_MAX_AGE = 86400
def _load_shared_full_dashboard_cache(now):
"""讀取跨 worker 共享的商品看板深度快取。"""
cache_file = str(_DASHBOARD_SHARED_CACHE_FILE)
def _new_dashboard_file_lock():
return FileLock(_DASHBOARD_LOCK_FILE)
def _load_dashboard_cache_file(now, cache_path, *, allow_stale=False, label='共享'):
"""讀取跨 worker 商品看板深度快取;必要時可讀舊快取救援首屏。"""
cache_file = str(cache_path)
if not os.path.exists(cache_file):
return None
@@ -537,7 +542,8 @@ def _load_shared_full_dashboard_cache(now):
return None
age = now.timestamp() - full_timestamp
if age >= _DASHBOARD_CACHE_TTL:
max_age = _DASHBOARD_STALE_CACHE_MAX_AGE if allow_stale else _DASHBOARD_CACHE_TTL
if age >= max_age:
return None
_DASHBOARD_DATA_CACHE['full_data'] = full_data
@@ -545,13 +551,35 @@ def _load_shared_full_dashboard_cache(now):
_DASHBOARD_DATA_CACHE['consolidated_data'] = payload.get('consolidated_data')
_DASHBOARD_DATA_CACHE['consolidated_timestamp'] = payload.get('consolidated_timestamp')
_DASHBOARD_DATA_CACHE['today_start'] = payload.get('today_start')
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用共享完整看板快取 | 快取年齡: {age:.0f}")
level = sys_log.info if allow_stale and age >= _DASHBOARD_CACHE_TTL else sys_log.debug
level(f"[Dashboard] [Cache] ✅ 使用{label}完整看板快取 | 快取年齡: {age:.0f}")
return full_data
except Exception as exc:
sys_log.warning(f"[Dashboard] [Cache] 共享快取讀取失敗,改走資料庫重建: {exc}")
sys_log.warning(f"[Dashboard] [Cache] {label}快取讀取失敗,改走資料庫重建: {exc}")
return None
def _load_shared_full_dashboard_cache(now):
return _load_dashboard_cache_file(now, _DASHBOARD_SHARED_CACHE_FILE)
def _load_stale_full_dashboard_cache(now):
stale_data = _load_dashboard_cache_file(
now,
_DASHBOARD_STALE_CACHE_FILE,
allow_stale=True,
label='舊版救援',
)
if stale_data:
return stale_data
return _load_dashboard_cache_file(
now,
_DASHBOARD_SHARED_CACHE_FILE,
allow_stale=True,
label='過期救援',
)
def _write_shared_full_dashboard_cache(full_data):
"""原子寫入跨 worker 共享的商品看板深度快取。"""
cache_file = str(_DASHBOARD_SHARED_CACHE_FILE)
@@ -576,6 +604,18 @@ def _write_shared_full_dashboard_cache(full_data):
except OSError:
pass
def warm_full_dashboard_cache(reason='manual'):
"""供 API、排程與 Gunicorn worker 啟動時預熱商品看板完整快取。"""
started = time.time()
data = get_full_dashboard_data(force_rebuild=True)
duration_ms = (time.time() - started) * 1000
sys_log.info(
f"[Dashboard] [Cache] ✅ 預熱完成 | reason={reason} | "
f"items={len(data.get('unique_items', [])) if data else 0} | 耗時={duration_ms:.0f}ms"
)
return data
# 慢查詢監控
_SLOW_QUERY_STATS = {
'total_queries': 0,
@@ -793,53 +833,62 @@ def get_consolidated_data():
session.close()
def get_full_dashboard_data():
def get_full_dashboard_data(force_rebuild=False):
"""獲取完整的看板資料,包含快取清單與全部 KPIs (深度快取)"""
global _DASHBOARD_DATA_CACHE
now = datetime.now(TAIPEI_TZ)
# V-Opt: 先檢查快取(無需鎖)
if _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'):
if not force_rebuild and _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'):
age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp']
if age < _DASHBOARD_CACHE_TTL:
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用完整看板快取 | 快取年齡: {age:.0f}")
return _DASHBOARD_DATA_CACHE['full_data']
shared_full_data = _load_shared_full_dashboard_cache(now)
shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
# V-Opt: 使用檔案鎖避免多 gunicorn worker 同時計算
lock_acquired = _DASHBOARD_FILE_LOCK.acquire(blocking=False)
dashboard_lock = _new_dashboard_file_lock()
lock_acquired = dashboard_lock.acquire(blocking=False)
if not lock_acquired:
# 如果無法取得鎖,表示其他 worker 正在重建,等待並使用更新後的快取
sys_log.debug("[Dashboard] [Cache] ⏳ 等待其他 worker 重建快取...")
_DASHBOARD_FILE_LOCK.acquire() # 等待取得鎖
_DASHBOARD_FILE_LOCK.release() # 立即釋放
shared_full_data = _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
if _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'):
age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp']
if age < _DASHBOARD_CACHE_TTL:
return _DASHBOARD_DATA_CACHE['full_data']
lock_acquired = _DASHBOARD_FILE_LOCK.acquire()
if not lock_acquired:
sys_log.warning("[Dashboard] [Cache] 共享鎖取得失敗,改用無鎖重建")
# 其他 worker 正在重建時,先用舊快取救首屏,避免使用者第一次打開卡住。
stale_full_data = None if force_rebuild else _load_stale_full_dashboard_cache(now)
if stale_full_data:
return stale_full_data
shared_full_data = _load_shared_full_dashboard_cache(now)
sys_log.info("[Dashboard] [Cache] ⏳ 等待其他 worker 重建快取...")
deadline = time.time() + 5
while time.time() < deadline:
time.sleep(0.25)
shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
dashboard_lock = _new_dashboard_file_lock()
lock_acquired = dashboard_lock.acquire(blocking=False)
if lock_acquired:
break
shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
if not lock_acquired:
stale_full_data = None if force_rebuild else _load_stale_full_dashboard_cache(now)
if stale_full_data:
return stale_full_data
dashboard_lock = _new_dashboard_file_lock()
lock_acquired = dashboard_lock.acquire(blocking=True)
try:
# 再次檢查快取(可能其他 worker 已經更新)
if _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'):
if not force_rebuild and _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'):
age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp']
if age < _DASHBOARD_CACHE_TTL:
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用完整看板快取 (其他 worker 已更新) | 快取年齡: {age:.0f}")
return _DASHBOARD_DATA_CACHE['full_data']
shared_full_data = _load_shared_full_dashboard_cache(now)
shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
@@ -981,7 +1030,7 @@ def get_full_dashboard_data():
finally:
# V-Opt: 確保釋放檔案鎖
if lock_acquired:
_DASHBOARD_FILE_LOCK.release()
dashboard_lock.release()
def get_dashboard_stats():

View File

@@ -7,6 +7,7 @@
import os
import io
import json
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, send_file, redirect, url_for, flash
from auth import login_required
@@ -153,6 +154,7 @@ def export_excel_ai_picks():
ar.confidence,
ar.sales_7d_delta,
ar.reason,
ar.model_footprint,
ar.created_at,
p.url AS momo_url,
vc.competitor_product_id,
@@ -179,6 +181,14 @@ def export_excel_ai_picks():
pchome_id = row.get('competitor_product_id') or ''
momo_url = row.get('momo_url') or f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={sku}"
pchome_url = f"https://24h.pchome.com.tw/prod/{str(pchome_id).strip()}" if pchome_id else ''
footprint = row.get('model_footprint') or {}
if isinstance(footprint, str):
try:
footprint = json.loads(footprint)
except Exception:
footprint = {}
agent_footprint = footprint.get('agent', {}) if isinstance(footprint, dict) else {}
missing_evidence = agent_footprint.get('missing_evidence') or []
export_rows.append({
'AI排名': int(row.get('rank') or 0),
'MOMO商品ID': sku,
@@ -188,6 +198,10 @@ def export_excel_ai_picks():
'PChome價格': float(row.get('pchome_price') or 0),
'價差百分比': float(row.get('gap_pct') or 0),
'AI信心百分比': round(float(row.get('confidence') or 0) * 100, 1),
'機會分數': float(agent_footprint.get('opportunity_score') or 0),
'證據完整度': float(agent_footprint.get('evidence_quality') or 0),
'信心分層': agent_footprint.get('confidence_band') or '',
'待補證據': ''.join(str(item) for item in missing_evidence),
'近7日銷售變化': float(row.get('sales_7d_delta') or 0),
'PChome商品ID': pchome_id,
'PChome商品名稱': row.get('competitor_product_name') or '',