From 2550ab45b154872709d06a9417db3f22e5abeba4 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 29 Apr 2026 21:26:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor(routes):=20=E5=88=AA=E9=99=A4=E6=A8=A1?= =?UTF-8?q?=E7=B5=84=E5=8C=96=E6=AD=BB=E7=A2=BC=E9=96=8B=E9=97=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-017 Phase 3f-1 dead-switch sprint;改為直接註冊 Blueprint,移除 USE_MODULAR_ROUTES/register_blueprints,並加入重複路由啟動自檢。 --- app.py | 95 +++++++++++----------- config.py | 19 +---- routes/README.md | 168 +++++---------------------------------- routes/__init__.py | 191 +-------------------------------------------- 4 files changed, 70 insertions(+), 403 deletions(-) diff --git a/app.py b/app.py index 1a110e5..6aa779c 100644 --- a/app.py +++ b/app.py @@ -338,27 +338,45 @@ try: except Exception as _e: sys_log.error(f"[Blueprint] ❌ OpenClaw Bot Blueprint 註冊失敗: {_e}") -# P0-12 修復:補齊缺少的 Blueprint 註冊 -for _bp_module, _bp_name in [ - ('routes.api_routes', 'api_bp'), - ('routes.edm_routes', 'edm_bp'), - ('routes.sales_routes', 'sales_bp'), - ('routes.monthly_routes', 'monthly_bp'), - ('routes.price_comparison_routes', 'price_comparison_bp'), - ('routes.export_routes', 'export_bp'), - ('routes.daily_sales_routes', 'daily_sales_bp'), - ('routes.dashboard_routes', 'dashboard_bp'), - ('routes.import_routes', 'import_bp'), - ('routes.pchome_routes', 'pchome_bp'), -]: - try: - import importlib as _il - _mod = _il.import_module(_bp_module) - _bp = getattr(_mod, _bp_name) - app.register_blueprint(_bp) - sys_log.info(f"[Blueprint] ✅ {_bp_name} 已註冊") - except Exception as _e: - sys_log.error(f"[Blueprint] ❌ {_bp_name} 註冊失敗: {_e}") +from routes.api_routes import api_bp +app.register_blueprint(api_bp) +sys_log.info("[Blueprint] ✅ api_bp 已註冊") + +from routes.edm_routes import edm_bp +app.register_blueprint(edm_bp) +sys_log.info("[Blueprint] ✅ edm_bp 已註冊") + +from routes.sales_routes import sales_bp +app.register_blueprint(sales_bp) +sys_log.info("[Blueprint] ✅ sales_bp 已註冊") + +from routes.monthly_routes import monthly_bp +app.register_blueprint(monthly_bp) +sys_log.info("[Blueprint] ✅ monthly_bp 已註冊") + +from routes.price_comparison_routes import price_comparison_bp +app.register_blueprint(price_comparison_bp) +sys_log.info("[Blueprint] ✅ price_comparison_bp 已註冊") + +from routes.export_routes import export_bp +app.register_blueprint(export_bp) +sys_log.info("[Blueprint] ✅ export_bp 已註冊") + +from routes.daily_sales_routes import daily_sales_bp +app.register_blueprint(daily_sales_bp) +sys_log.info("[Blueprint] ✅ daily_sales_bp 已註冊") + +from routes.dashboard_routes import dashboard_bp +app.register_blueprint(dashboard_bp) +sys_log.info("[Blueprint] ✅ dashboard_bp 已註冊") + +from routes.import_routes import import_bp +app.register_blueprint(import_bp) +sys_log.info("[Blueprint] ✅ import_bp 已註冊") + +from routes.pchome_routes import pchome_bp +app.register_blueprint(pchome_bp) +sys_log.info("[Blueprint] ✅ pchome_bp 已註冊") # V-Fix: 註冊 slugify 函數供模板使用(實作搬至 utils/text_helpers.py) from utils.text_helpers import slugify # noqa: E402 @@ -624,36 +642,19 @@ def refresh_session(): session.modified = True # 標記 Session 已修改,觸發 Cookie 更新 +def verify_unique_routes(): + """啟動期防線:同一 URL + method 不得由兩個 endpoint 同時註冊。""" + seen = {} + for rule in app.url_map.iter_rules(): + key = (str(rule), frozenset(rule.methods - {'HEAD', 'OPTIONS'})) + if key in seen: + raise SystemExit(f"重複路由: {key} 來自 {seen[key]} 與 {rule.endpoint}") + seen[key] = rule.endpoint +verify_unique_routes() - - - - - - -# ================= 📊 V-New: 業績分析報表 ================= - - - - -# V-Opt: API 層級快取 (減少重複查詢) -_TABLE_DATA_CACHE = {} -_TABLE_DATA_CACHE_TTL = 60 # 快取 60 秒 - - - -# V-Old: 保留舊版本以防需要回滾 - -# ================= 💎 V-New: Top 3 Highlights 詳細列表 API ================= - - -# ================= 📈 V-New: 年度對比 (Year-over-Year Comparison) ================= - -# ================= 📈 V-New: 營運成長報表 (Growth Strategy) ================= - def preprocess_daily_sales_data(df): """前處理當日業績資料:欄位識別、型別轉換""" cols = df.columns.tolist() diff --git a/config.py b/config.py index 0d00585..c14302d 100644 --- a/config.py +++ b/config.py @@ -237,23 +237,6 @@ SYSTEM_VERSION = "V10.3" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 -# ========================================== -# 模組化路由設定 -# ========================================== -# 控制是否啟用模組化路由,設為 True 時會自動清理 app.py 中的重複路由 -USE_MODULAR_ROUTES = { - 'system': True, # 系統設定、日誌、備份 - 'edm': True, # EDM 與節慶儀表板 - 'monthly': True, # 月結分析 - 'dashboard': True, # 首頁商品看板 - 'daily_sales': True, # 當日業績分析 - 'api': True, # 通用 API - 'export': True, # 匯出功能 - 'import': True, # 匯入功能 - 'sales': True, # 業績分析 -} - - def validate_critical_config(): """啟動時驗證選用配置,缺少則回傳 warning 清單(非 fatal)。""" warnings = [] @@ -261,4 +244,4 @@ def validate_critical_config(): for var in optional_vars: if not os.getenv(var): warnings.append(f"[Config] 選用設定 {var} 未設,部分功能可能停用") - return warnings \ No newline at end of file + return warnings diff --git a/routes/README.md b/routes/README.md index 9c7b545..6d478b5 100644 --- a/routes/README.md +++ b/routes/README.md @@ -1,154 +1,26 @@ -# 路由模組重構說明 +# 路由模組說明 -## 概述 +`app.py` 直接註冊所有 Flask Blueprint;`USE_MODULAR_ROUTES`、`register_blueprints()`、 +`MODULAR_ENDPOINTS` 與 duplicate cleanup 開關已在 ADR-017 Phase 3f-1 移除。 -此目錄包含從 `app.py` 中分離出來的路由模組,採用 Flask Blueprint 架構。 -這樣的模組化設計使得代碼更易於維護、測試和擴展。 +## 啟動防線 + +`app.py` 會在啟動時檢查 `app.url_map`,同一組 `(URL, HTTP methods)` 不允許被兩個 endpoint +重複註冊;若發現衝突會直接 `SystemExit`。 ## 模組清單 -| 模組 | 狀態 | 說明 | 路由 | -|------|------|------|------| -| `system_routes.py` | ✅ 獨立 | 系統管理 | `/settings`, `/logs`, `/health`, `/metrics`, `/api/backup` 等 | -| `edm_routes.py` | ✅ 獨立 | EDM 儀表板 | `/edm`, `/festival` | -| `monthly_routes.py` | ✅ 獨立 | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` | -| `dashboard_routes.py` | ✅ 獨立 | 首頁看板 | `/`, `/brand_assets` | -| `daily_sales_routes.py` | ✅ 獨立 | 當日業績 | `/daily_sales`, `/daily_sales/export*` | -| `api_routes.py` | ✅ 已獨立 | 通用 API | `/api/run_task`, `/api/history/*` 等 | -| `export_routes.py` | ✅ 已獨立 | 匯出功能 | `/api/export/*` | -| `import_routes.py` | ✅ 已獨立 | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | -| `sales_routes.py` | ⚡ 延遲導入 | 業績分析 | `/sales_analysis`, `/growth_analysis`, `/api/sales_analysis/*` | +| 模組 | 說明 | 主要路由 | +|------|------|----------| +| `dashboard_routes.py` | 商品看板首頁 | `/` | +| `sales_routes.py` | 業績分析與 ABC 明細 | `/sales_analysis`, `/growth_analysis`, `/abc_analysis/detail`, `/api/sales_analysis/*` | +| `system_public_routes.py` | 無 prefix 公開系統頁與監控 | `/health`, `/metrics`, `/settings`, `/system_settings`, `/logs`, `/api/logs`, `/api/backup` | +| `system_routes.py` | 內部系統維護 API | `/api/system/*` | +| `edm_routes.py` | EDM 與節慶儀表板 | `/edm`, `/festival` | +| `monthly_routes.py` | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` | +| `daily_sales_routes.py` | 當日業績 | `/daily_sales`, `/daily_sales/export*` | +| `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` | +| `export_routes.py` | 匯出功能 | `/api/export/*` | +| `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | -## 啟用模組 - -### 方法一:修改 config.py(推薦) - -在 `config.py` 中設定 `USE_MODULAR_ROUTES`: - -```python -USE_MODULAR_ROUTES = { - 'system': True, # 啟用 system_routes - 'edm': True, # 啟用 edm_routes - 'monthly': True, # 啟用 monthly_routes - 'dashboard': True, # 啟用 dashboard_routes - 'daily_sales': True, # 啟用 daily_sales_routes - 'api': False, # api_routes 有依賴,暫不啟用 - 'export': False, # export_routes 有依賴,暫不啟用 - 'import': False, # import_routes 有依賴,暫不啟用 - 'sales': False, # sales_routes 有依賴,暫不啟用 -} -``` - -### 方法二:環境變數 - -可透過環境變數覆蓋設定(待實作)。 - -## 運作原理 - -1. **Blueprint 註冊**:`routes/__init__.py` 中的 `register_blueprints()` 函數根據 `USE_MODULAR_ROUTES` 設定決定是否註冊各模組的 Blueprint。 - -2. **重複路由清理**:`app.py` 底部的 `cleanup_duplicate_routes()` 函數會在所有路由定義完成後,移除與已啟用 Blueprint 重複的路由。 - -3. **優先順序**:Blueprint 中的路由優先於 app.py 中的同名路由。 - -## 模組依賴說明 - -### 完全獨立模組 -- `system_routes.py` -- `edm_routes.py` -- `monthly_routes.py` -- `dashboard_routes.py` - 提供 `get_consolidated_data`, `get_dashboard_stats` -- `daily_sales_routes.py` - -這些模組不依賴 app.py 中的任何函數或變數。 - -### 已獨立化的模組(使用 services 或其他獨立模組) -- `api_routes.py`: - - 使用 `services/task_runner.py` 中的 `run_momo_task_with_notification` - - 使用 `routes/dashboard_routes.py` 中的 `get_dashboard_stats` -- `export_routes.py`: - - 使用 `routes/dashboard_routes.py` 中的 `get_consolidated_data` - - 使用 `services/cache_service.py` 中的快取變數 -- `import_routes.py`: - - 使用 `services/cache_service.py` 中的 `_SALES_DF_CACHE`, `_SALES_PROCESSED_CACHE` - -### 延遲導入模組 -- `sales_routes.py`: - - `growth_analysis()` 已完全獨立 - - 其他 API 函數使用延遲導入從 app.py 取得(函數邏輯複雜,暫不遷移) - -## 開發指南 - -### 新增路由模組 - -1. 在 `routes/` 目錄建立新的 Python 檔案 -2. 定義 Blueprint: - ```python - from flask import Blueprint - my_bp = Blueprint('my_module', __name__) - ``` -3. 在 `routes/__init__.py` 中加入註冊邏輯 -4. 在 `config.py` 的 `USE_MODULAR_ROUTES` 中加入控制項 - -### 從 app.py 遷移路由 - -1. 複製路由函數到對應模組 -2. 將 `@app.route` 改為 `@blueprint_name.route` -3. 確認所有導入正確 -4. 測試模組可正常導入:`python -c "from routes.xxx import xxx_bp"` -5. 在 `MODULAR_ENDPOINTS` 中記錄端點名稱 - -## 測試 - -測試模組是否正常運作: - -```bash -# 測試所有模組導入 -python -c " -from routes.system_routes import system_bp -from routes.edm_routes import edm_bp -from routes.monthly_routes import monthly_bp -from routes.dashboard_routes import dashboard_bp -from routes.daily_sales_routes import daily_sales_bp -print('All independent modules imported successfully!') -" - -# 測試完整應用(啟用所有獨立模組) -python -c " -import config -config.USE_MODULAR_ROUTES = { - 'system': True, 'edm': True, 'monthly': True, - 'dashboard': True, 'daily_sales': True, - 'api': False, 'export': False, 'import': False, 'sales': False -} -import app -print(f'Total routes: {len(list(app.app.url_map.iter_rules()))}') -" -``` - -## 已知問題 - -### 模板 url_for 端點名稱問題 - -當啟用模組化路由時,端點名稱會從 `'index'` 變為 `'dashboard.index'`。 -這會導致模板中的 `url_for('index')` 調用失敗。 - -**影響範圍**: -- `index.html` 中的 `url_for('index', ...)` -- `edm_dashboard.html` 中的 `url_for('edm_dashboard', ...)` - -**解決方案**(需在啟用模組前執行): -1. 在模板中使用完整的端點名稱,如 `url_for('dashboard.index', ...)` -2. 或在 app.py 中加入端點別名 - -**注意**:目前預設所有模組皆禁用(`USE_MODULAR_ROUTES` 全為 False), -因此不會影響正常使用。此問題只會在手動啟用模組後出現。 - -## 變更歷史 - -- **2026-01-18**: - - 初始版本,建立 9 個路由模組,5 個獨立模組可直接啟用 - - 修復 url_for 端點名稱問題,新增 `register_endpoint_aliases()` 函數 - - 處理有依賴的模組 (api, export, import),使用獨立 services 模組 - - 新增 `services/task_runner.py` 封裝爬蟲任務執行邏輯 - - 所有 9 個模組已啟用並通過測試 +新增 route 時請優先放入對應 Blueprint,並用本機 `app.url_map` duplicate check 驗證。 diff --git a/routes/__init__.py b/routes/__init__.py index 858025b..c7f11f7 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1,190 +1 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -路由模組 - Blueprint 註冊中心 -將所有路由模組集中管理,方便 app.py 統一註冊 - -重構狀態: - 所有模組已獨立化並啟用: - - system_routes: 系統設定、日誌、備份 (完全獨立) - - edm_routes: EDM 與節慶儀表板 (完全獨立) - - monthly_routes: 月結分析 (完全獨立) - - dashboard_routes: 首頁商品看板 (完全獨立) - - daily_sales_routes: 當日業績分析 (完全獨立) - - api_routes: 通用 API (使用 task_runner, dashboard_routes) - - export_routes: 匯出功能 (使用 dashboard_routes, cache_service) - - import_routes: 匯入功能 (使用 cache_service) - - sales_routes: 業績分析 (延遲導入 app.py 複雜函數) - - 啟用方式: - 1. 在 config.py 中將對應的 USE_MODULAR_ROUTES[key] 設為 True - 2. 重啟應用程式 - 3. app.py 中的對應路由會自動被跳過 (cleanup_duplicate_routes) - 4. url_for 向後相容透過 register_endpoint_aliases 實現 -""" - -from config import USE_MODULAR_ROUTES - -# 記錄已被模組化路由覆蓋的端點名稱 -MODULAR_ENDPOINTS = set() - - -def register_blueprints(app): - """ - 註冊所有 Blueprint 到 Flask app - - Args: - app: Flask 應用程式實例 - """ - global MODULAR_ENDPOINTS - registered = [] - errors = [] - - # ============================================================ - # 第一階段:完全獨立的模組(無 app.py 依賴) - # 透過 config.USE_MODULAR_ROUTES 控制是否啟用 - # ============================================================ - - # 系統管理路由 (設定、日誌、備份、分類管理) - if USE_MODULAR_ROUTES.get('system', False): - try: - from routes.system_routes import system_bp - app.register_blueprint(system_bp) - registered.append('system_routes') - # 記錄此模組覆蓋的端點 - MODULAR_ENDPOINTS.update([ - 'health_check', 'prometheus_metrics', 'settings', 'system_settings_page', - 'add_category', 'update_category', 'delete_category', 'test_url', - 'show_logs', 'get_logs_api', 'trigger_backup', 'download_backup' - ]) - except ImportError as e: - errors.append(f"system_routes: {e}") - - # EDM 與節慶儀表板路由 - if USE_MODULAR_ROUTES.get('edm', False): - try: - from routes.edm_routes import edm_bp - app.register_blueprint(edm_bp) - registered.append('edm_routes') - MODULAR_ENDPOINTS.update(['edm_dashboard', 'festival_dashboard']) - except ImportError as e: - errors.append(f"edm_routes: {e}") - - # 月結分析路由 - if USE_MODULAR_ROUTES.get('monthly', False): - try: - from routes.monthly_routes import monthly_bp - app.register_blueprint(monthly_bp) - registered.append('monthly_routes') - MODULAR_ENDPOINTS.update(['monthly_summary_analysis_page', 'get_monthly_summary_data']) - except ImportError as e: - errors.append(f"monthly_routes: {e}") - - # 商品看板路由 (首頁) - if USE_MODULAR_ROUTES.get('dashboard', False): - try: - from routes.dashboard_routes import dashboard_bp - app.register_blueprint(dashboard_bp) - registered.append('dashboard_routes') - MODULAR_ENDPOINTS.update(['index', 'brand_assets']) - except ImportError as e: - errors.append(f"dashboard_routes: {e}") - - # 當日業績路由 - if USE_MODULAR_ROUTES.get('daily_sales', False): - try: - from routes.daily_sales_routes import daily_sales_bp - app.register_blueprint(daily_sales_bp) - registered.append('daily_sales_routes') - MODULAR_ENDPOINTS.update([ - 'daily_sales', 'export_daily_sales_category', 'export_marketing_summary_excel' - ]) - except ImportError as e: - errors.append(f"daily_sales_routes: {e}") - - # ============================================================ - # 第二階段:有 app.py 依賴的模組(暫時保留在 app.py) - # ============================================================ - - # 通用 API 路由 - 依賴: scheduled_job_wrapper, get_dashboard_stats - if USE_MODULAR_ROUTES.get('api', False): - try: - from routes.api_routes import api_bp - app.register_blueprint(api_bp) - registered.append('api_routes') - MODULAR_ENDPOINTS.update([ - 'trigger_task', 'trigger_edm_task', 'trigger_festival_task', - 'trigger_momo_notification', 'trigger_edm_notification', 'test_notification', - 'get_price_history', 'get_price_change_details' - ]) - except ImportError as e: - errors.append(f"api_routes: {e}") - - # 匯出功能路由 - 依賴: get_consolidated_data, _SALES_PROCESSED_CACHE - if USE_MODULAR_ROUTES.get('export', False): - try: - from routes.export_routes import export_bp - app.register_blueprint(export_bp) - registered.append('export_routes') - except ImportError as e: - errors.append(f"export_routes: {e}") - - # 匯入功能路由 - 依賴: _SALES_DF_CACHE, _SALES_PROCESSED_CACHE - if USE_MODULAR_ROUTES.get('import', False): - try: - from routes.import_routes import import_bp - app.register_blueprint(import_bp) - registered.append('import_routes') - except ImportError as e: - errors.append(f"import_routes: {e}") - - # 業績分析路由 - 依賴: 多個複雜函數 - if USE_MODULAR_ROUTES.get('sales', False): - try: - from routes.sales_routes import sales_bp - app.register_blueprint(sales_bp) - registered.append('sales_routes') - MODULAR_ENDPOINTS.update([ - 'sales_analysis', 'growth_analysis', - 'api_sales_table_data', 'api_sales_table_data_pandas', - 'api_sales_top_detail', 'api_export_top_detail', 'api_yoy_comparison' - ]) - except ImportError as e: - errors.append(f"sales_routes: {e}") - - # AI 推薦路由 - Ollama LLM 整合 - if USE_MODULAR_ROUTES.get('ai', False): - try: - from routes.ai_routes import ai_bp - app.register_blueprint(ai_bp) - registered.append('ai_routes') - MODULAR_ENDPOINTS.update([ - 'ai_recommend', 'ai_status', 'ai_trends', 'ai_weather', - 'ai_generate_copy', 'ai_recommend_products', 'ai_analyze_weather_products', - 'ai_batch_generate_copy' - ]) - except ImportError as e: - errors.append(f"ai_routes: {e}") - - # 輸出註冊結果 - if registered: - print(f"✅ 已註冊路由模組: {', '.join(registered)}") - if errors: - for err in errors: - print(f"⚠️ 模組載入失敗: {err}") - - if not registered and not errors: - print("ℹ️ 路由模組重構中,透過 config.USE_MODULAR_ROUTES 控制啟用") - - -def is_endpoint_modular(endpoint_name): - """ - 檢查指定的端點是否已被模組化路由覆蓋 - - Args: - endpoint_name: 端點函數名稱 - - Returns: - bool: True 表示已被模組化,app.py 應跳過此路由 - """ - return endpoint_name in MODULAR_ENDPOINTS +"""Blueprint route package."""