fix(dashboard): prewarm cache and expose pick evidence
All checks were successful
CD Pipeline / deploy (push) Successful in 2m20s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m20s
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
Reference in New Issue
Block a user