From 9528d6c23ed4fc1c6569ff4572920e3cf54d55d6 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 29 Apr 2026 21:39:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(stability):=20=E8=A3=9C=E5=BC=B7=20schedule?= =?UTF-8?q?r=20=E4=BE=8B=E5=A4=96=E8=99=95=E7=90=86=E8=88=87=20vendor=20?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E8=B7=AF=E5=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-017 Phase 3f-3:移除 scheduler 裸 except,P1 任務失敗改走 EventRouter notify_failure 同步入口;清理 docker-compose vendor 死 mount;修正 vendor_bp template_folder 指向專案 web/templates。 --- docker-compose.yml | 8 ----- routes/vendor_routes.py | 5 ++- scheduler.py | 67 +++++++++++++++++++++------------------- services/event_router.py | 67 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 43 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f547882..982ea99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/routes/vendor_routes.py b/routes/vendor_routes.py index c741c5a..0c107f5 100644 --- a/routes/vendor_routes.py +++ b/routes/vendor_routes.py @@ -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) diff --git a/scheduler.py b/scheduler.py index 10da552..62d9390 100644 --- a/scheduler.py +++ b/scheduler.py @@ -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: 走 EventRouter(Hermes 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:
倒數:92
@@ -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: 改走 EventRouter(Hermes 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 執行。") \ No newline at end of file + logging.info("[Scheduler] [Main] 此為排程任務定義檔,請由主程式 app.py 執行。") diff --git a/services/event_router.py b/services/event_router.py index 7b9c7d3..794d151 100644 --- a/services/event_router.py +++ b/services/event_router.py @@ -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"))