fix(stability): 補強 scheduler 例外處理與 vendor 模板路徑

ADR-017 Phase 3f-3:移除 scheduler 裸 except,P1 任務失敗改走 EventRouter notify_failure 同步入口;清理 docker-compose vendor 死 mount;修正 vendor_bp template_folder 指向專案 web/templates。
This commit is contained in:
OoO
2026-04-29 21:39:25 +08:00
parent 13fa165ee2
commit 9528d6c23e
4 changed files with 104 additions and 43 deletions

View File

@@ -79,14 +79,6 @@ services:
- ./auto_import_index.html:/app/auto_import_index.html:ro
- ./settings.html:/app/settings.html:ro
- ./system_settings.html:/app/system_settings.html:ro
# 廠商缺貨系統
- ./vendor_routes.py:/app/vendor_routes.py:ro
- ./vendor_stockout_index.html:/app/vendor_stockout_index.html:ro
- ./vendor_stockout_import.html:/app/vendor_stockout_import.html:ro
- ./vendor_stockout_list.html:/app/vendor_stockout_list.html:ro
- ./vendor_stockout_send_email.html:/app/vendor_stockout_send_email.html:ro
- ./vendor_stockout_history.html:/app/vendor_stockout_history.html:ro
- ./vendor_management.html:/app/vendor_management.html:ro
# 其他根目錄路由
- ./auto_import_routes.py:/app/auto_import_routes.py:ro
- ./crawler_management_routes.py:/app/crawler_management_routes.py:ro

View File

@@ -23,9 +23,8 @@ from services.logger_manager import SystemLogger
sys_log = SystemLogger("VendorRoutes").get_logger()
# 建立 Blueprint
# V-Fix (2026-01-23): 指定正確的 template_folder解決 TemplateNotFound 錯誤
import os
_base_dir = os.path.dirname(os.path.abspath(__file__))
# V-Fix: 指定正確的 template_folder解決 TemplateNotFound 錯誤
_base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_template_folder = os.path.join(_base_dir, 'web', 'templates')
vendor_bp = Blueprint('vendor', __name__, url_prefix='/vendor-stockout', template_folder=_template_folder)

View File

@@ -240,7 +240,8 @@ def run_momo_task():
logging.warning(f"[Crawler] [MOMO] ⚠️ 頁面載入超時 (15s),強制停止載入並嘗試解析內容...")
try:
driver.execute_script("window.stop();")
except: pass
except Exception as e:
logging.exception(f"[Crawler] [MOMO] window.stop() 失敗但繼續 | Category: {cat_name} | Error: {e}")
# V-Opt: eager 模式下get 會很快返回,這裡稍微等待一下 DOM 穩定
time.sleep(1)
@@ -251,7 +252,8 @@ def run_momo_task():
for j in range(1, 3): # V-Opt: 再減少一次滾動 (4 -> 3),通常前兩屏即包含大部分熱銷商品
driver.execute_script(f"window.scrollTo(0, {j * 1500});") # 加大滾動距離
time.sleep(0.5)
except Exception: pass
except Exception as e:
logging.exception(f"[Crawler] [MOMO] 滾動頁面失敗但繼續 | Category: {cat_name} | Error: {e}")
# === V9.95: 改為容器優先的爬取策略,提高穩定性 ===
# 1. 找出所有可能的商品容器
@@ -467,18 +469,16 @@ def run_momo_task():
_save_stats('momo_task', stats)
# ADR-012 Phase 2: 走 EventRouterHermes L1 翻譯 + 三層式訊息)
try:
from services.event_router import dispatch as _dispatch
_dispatch({
"source": "Scheduler.MOMOCrawler",
"event_type": "crawler_timeout",
"severity": "alert",
"title": "MOMO 爬蟲任務中斷",
"status": "任務失敗",
"impact": "P1 - 熱銷商品監控中斷",
"summary": str(e)[:200],
"trace": _tb.format_exc(),
"payload": {"task_name": "run_momo_task"},
})
from services.event_router import notify_failure
notify_failure(
task_name="run_momo_task",
error=e,
source="Scheduler.MOMOCrawler",
event_type="crawler_timeout",
priority="P1",
title="MOMO 爬蟲任務中斷",
trace=_tb.format_exc(),
)
except Exception as _router_e:
logging.error(f"[Crawler] [MOMO] event_router 失敗: {_router_e}")
finally:
@@ -579,12 +579,14 @@ def run_edm_task(lpn_code="O1K5FBOqsvN"):
brand_text = ""
try:
brand_text = item.find_element(By.CSS_SELECTOR, ".brand").text.strip()
except: pass
except Exception as e:
logging.exception(f"[Crawler] [EDM] 解析品牌失敗但繼續 | Block: {i+1} | Error: {e}")
name_text = ""
try:
name_text = item.find_element(By.CSS_SELECTOR, ".brand2").text.strip()
except: pass
except Exception as e:
logging.exception(f"[Crawler] [EDM] 解析品名失敗但繼續 | Block: {i+1} | Error: {e}")
name = f"{brand_text} {name_text}".strip()
@@ -650,7 +652,8 @@ def run_edm_task(lpn_code="O1K5FBOqsvN"):
disc_el = item.find_element(By.CSS_SELECTOR, ".discount span")
if disc_el:
discount_text = disc_el.text.strip() + ""
except: pass
except Exception as e:
logging.exception(f"[Crawler] [EDM] 解析折扣失敗但繼續 | i_code: {i_code} | Error: {e}")
# V9.66: 解析倒數組數
# HTML: <div class="last">倒數:<span id="gdsStock_1">92</span><small>組</small></div>
@@ -887,7 +890,8 @@ def run_festival_task(lpn_code="O7ylWfihYUM"):
logging.warning("[Crawler] [Festival] ⚠️ 頁面載入超時 (120s),嘗試停止載入並繼續解析...")
try:
driver.execute_script("window.stop();")
except: pass
except Exception as e:
logging.exception(f"[Crawler] [Festival] window.stop() 失敗但繼續 | Error: {e}")
# V-Fix: 增加初始等待時間,確保頁面上的 Vue.js 框架有足夠時間初始化並掛載懶加載事件
time.sleep(10)
@@ -1219,7 +1223,8 @@ def run_promo_event_task(lpn_code, page_type, activity_name):
logging.warning(f"[Crawler] [{page_type.upper()}] ⚠️ 頁面載入超時 (120s),嘗試停止載入並繼續解析...")
try:
driver.execute_script("window.stop();")
except: pass
except Exception as e:
logging.exception(f"[Crawler] [{page_type.upper()}] window.stop() 失敗但繼續 | Error: {e}")
time.sleep(10)
logging.info(f"[Crawler] [{page_type.upper()}] 📄 頁面標題: {driver.title}")
@@ -1876,18 +1881,16 @@ def run_auto_import_task():
# ADR-012 Phase 2: 改走 EventRouterHermes L1 翻譯 + 降級鏈)
# LINE 通道保留event_router 只處理 Telegram
try:
from services.event_router import dispatch as _dispatch
_dispatch({
"source": "Scheduler.AutoImport",
"event_type": "db_connection_error" if "translate host" in str(e).lower() or "operational" in str(e).lower() else "import_failure",
"severity": "alert",
"title": "當日業績自動匯入異常",
"status": "匯入失敗",
"impact": "P1 - 當日業績未更新",
"summary": str(e)[:200],
"trace": _tb.format_exc(),
"payload": {"task_name": "run_auto_import_task"},
})
from services.event_router import notify_failure
notify_failure(
task_name="run_auto_import_task",
error=e,
source="Scheduler.AutoImport",
event_type="db_connection_error" if "translate host" in str(e).lower() or "operational" in str(e).lower() else "import_failure",
priority="P1",
title="當日業績自動匯入異常",
trace=_tb.format_exc(),
)
except Exception as _router_e:
logging.error(f"[Scheduler] [AutoImport] event_router 失敗: {_router_e}")
@@ -2331,4 +2334,4 @@ def run_monthly_report_task():
if __name__ == "__main__":
# 此檔案現在由 app.py 導入並由其主執行緒管理排程。
# 若需獨立測試,可在此處臨時加入調用程式碼。
logging.info("[Scheduler] [Main] 此為排程任務定義檔,請由主程式 app.py 執行。")
logging.info("[Scheduler] [Main] 此為排程任務定義檔,請由主程式 app.py 執行。")

View File

@@ -5,6 +5,8 @@
#
import asyncio
import logging
import threading
import traceback
from typing import Any, Dict, Optional
from services.ai_orchestrator import AIOrchestrator
@@ -103,6 +105,71 @@ async def dispatch(event: Dict[str, Any], admin_chat_ids: Optional[list] = None)
}
def _run_coroutine_in_thread(coro) -> Dict[str, Any]:
result = {}
def runner():
try:
result["value"] = asyncio.run(coro)
except Exception as e:
result["value"] = {
"tier": "unknown",
"sent": 0,
"errors": [str(e)],
"latency_ms": 0,
"payload": None,
}
thread = threading.Thread(target=runner, daemon=True)
thread.start()
thread.join(timeout=15)
if thread.is_alive():
return {
"tier": "unknown",
"sent": 0,
"errors": ["dispatch_sync timed out"],
"latency_ms": 15000,
"payload": None,
}
return result["value"]
def dispatch_sync(event: Dict[str, Any], admin_chat_ids: Optional[list] = None) -> Dict[str, Any]:
"""同步環境使用的 EventRouter 入口。"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(dispatch(event, admin_chat_ids=admin_chat_ids))
return _run_coroutine_in_thread(dispatch(event, admin_chat_ids=admin_chat_ids))
def notify_failure(
task_name: str,
error: Exception,
*,
source: Optional[str] = None,
event_type: str = "scheduler_task_failure",
priority: str = "P2",
title: Optional[str] = None,
trace: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""排程/背景任務失敗的同步通知 helper。"""
severity = "alert" if priority in {"P1", "P2"} else "warning"
event = {
"source": source or f"Scheduler.{task_name}",
"event_type": event_type,
"severity": severity,
"title": title or f"{task_name} 任務異常",
"status": "任務失敗",
"impact": f"{priority} - 背景任務需要檢查",
"summary": str(error)[:200],
"trace": trace or "".join(traceback.format_exception(type(error), error, error.__traceback__)),
"payload": {"task_name": task_name, **(payload or {})},
}
return dispatch_sync(event)
def _classify(event: Dict[str, Any]) -> str:
sev = event.get("severity", "info")
has_trace = bool(event.get("trace"))