Files
ewoooc/routes/system_public_routes.py
ogt 903cf1a27a
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
fix: align deploy health checks with live endpoint
2026-06-25 14:45:02 +08:00

570 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""公開系統頁面與健康檢查路由。
此 blueprint 無 url_prefix保留外部監控與既有前端使用的公開 URL。
"""
import os
import hmac
import mimetypes
import posixpath
import zipfile
from datetime import datetime, timezone, timedelta
from urllib.parse import quote
from flask import Blueprint, Response, jsonify, render_template, request, send_from_directory, session, url_for
import requests
from sqlalchemy import text
from auth import login_required
from config import (
BASE_DIR,
DATABASE_TYPE,
SYSTEM_VERSION,
WEBCRUMBS_ASSET_UPSTREAM_URL,
WEBCRUMBS_ENABLED,
)
from database.manager import DatabaseManager
from database.models import Product, PriceRecord
from services.external_tool_payload_service import (
build_external_tool_payload,
build_webcrumbs_seed_data,
)
from services.json_storage import load_categories
from services.logger_manager import SystemLogger
from utils.security import safe_join
system_public_bp = Blueprint('system_public', __name__)
sys_log = SystemLogger("SystemPublicRoutes").get_logger()
TAIPEI_TZ = timezone(timedelta(hours=8))
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = os.getenv('PUBLIC_URL', '服務啟動中...')
STATIC_DIR = os.path.join(BASE_DIR, 'web/static')
WEBCRUMBS_ASSET_ALLOWED_PREFIXES = ('loader/', 'plugins/', 'demo/')
WEBCRUMBS_COMPATIBLE_LOADER_PATH = 'loader/webcrumbs-compatible-loader.js'
WEBCRUMBS_FALLBACK_LOADER = """
(() => {
const state = { status: 'fallback', reason: 'upstream_unavailable' };
window.WebcrumbsRuntime = Object.assign(window.WebcrumbsRuntime || {}, state);
if (!customElements.get('stock-platform-plugin')) {
customElements.define('stock-platform-plugin', class extends HTMLElement {
connectedCallback() {
if (this.dataset.webcrumbsFallbackRendered === '1') return;
this.dataset.webcrumbsFallbackRendered = '1';
const uri = this.getAttribute('uri') || '';
const box = document.createElement('div');
box.setAttribute('role', 'status');
box.style.cssText = 'border:1px solid rgba(42,37,32,.14);border-radius:8px;padding:12px;background:#fffaf0;color:#3b332b;font:600 13px/1.5 system-ui,sans-serif;';
const title = document.createElement('strong');
title.textContent = 'Webcrumbs runtime temporarily offline';
const detail = document.createElement('div');
detail.textContent = uri ? `Plugin waiting for runtime: ${uri}` : 'Plugin waiting for runtime.';
box.append(title, detail);
this.replaceChildren(box);
}
});
}
window.dispatchEvent(new CustomEvent('webcrumbs:runtime-fallback', { detail: state }));
})();
""".strip()
def _has_sensitive_webcrumbs_access():
if session.get('logged_in'):
return True
internal_key = os.getenv('INTERNAL_API_KEY', '').strip()
provided = request.headers.get('X-Internal-Key', '').strip()
return bool(internal_key and provided and hmac.compare_digest(provided, internal_key))
@system_public_bp.route('/favicon.ico')
def favicon():
"""使用既有品牌圖示回應瀏覽器預設 favicon 探測,避免全站 404 噪音。"""
return send_from_directory(
os.path.join(STATIC_DIR, 'images'),
'logo_circle.svg',
mimetype='image/svg+xml',
max_age=604800,
)
@system_public_bp.route('/health')
def health_check():
"""健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用"""
try:
return jsonify({
'status': 'healthy',
'database': DATABASE_TYPE,
'version': SYSTEM_VERSION
}), 200
except Exception as e:
return jsonify({
'status': 'unhealthy',
'error': str(e)
}), 500
@system_public_bp.route('/metabase')
@system_public_bp.route('/metabase/')
@login_required
def metabase_status():
"""Internal status page for the BI entrypoint when the public proxy is not attached."""
return render_template(
'external_tool_status.html',
active_page='metabase',
system_version=SYSTEM_VERSION,
tool=build_external_tool_payload('metabase'),
)
@system_public_bp.route('/grist')
@system_public_bp.route('/grist/')
@login_required
def grist_status():
"""Internal status page for the data collaboration entrypoint."""
return render_template(
'external_tool_status.html',
active_page='grist',
system_version=SYSTEM_VERSION,
tool=build_external_tool_payload('grist'),
)
@system_public_bp.route('/webcrumbs')
@system_public_bp.route('/webcrumbs/')
@login_required
def webcrumbs_status():
"""Internal status page for the shared Webcrumbs microfrontend runtime."""
return render_template(
'external_tool_status.html',
active_page='webcrumbs',
system_version=SYSTEM_VERSION,
tool=build_external_tool_payload(
'webcrumbs',
include_host_data=_has_sensitive_webcrumbs_access(),
),
)
@system_public_bp.route('/api/webcrumbs/marketplace-host-data')
@login_required
def webcrumbs_marketplace_host_data_api():
"""Read-only host data contract for Webcrumbs shared-ui plugins."""
if not _has_sensitive_webcrumbs_access():
return jsonify({
'success': False,
'error': 'auth_required',
'message': 'MOMO/PChome host data requires a logged-in session or X-Internal-Key.',
'boundary': {
'auth_required': True,
'writes_database': False,
'calls_llm': False,
'fetches_external': False,
},
}), 401
limit = request.args.get('limit', 5, type=int) or 5
limit = max(1, min(limit, 8))
payload = build_webcrumbs_seed_data(limit=limit)
return jsonify({
'success': True,
'data': payload,
'metadata': payload.get('metadata') or {},
'boundary': {
'auth_required': True,
'writes_database': False,
'calls_llm': False,
'fetches_external': False,
'allowed_match_contract': 'exact/total_price/price_alert_exact',
},
})
def _normalize_webcrumbs_asset_path(asset_path):
candidate = (asset_path or '').strip().replace('\\', '/')
normalized = posixpath.normpath(candidate).lstrip('/')
if normalized in {'', '.'} or normalized != candidate.lstrip('/'):
return ''
if not normalized.startswith(WEBCRUMBS_ASSET_ALLOWED_PREFIXES):
return ''
return normalized
def _webcrumbs_fallback_loader_response(reason):
response = Response(WEBCRUMBS_FALLBACK_LOADER, status=200, mimetype='application/javascript')
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
response.headers['X-Webcrumbs-Fallback'] = reason
return response
@system_public_bp.route('/webcrumbs-assets/<path:asset_path>')
def webcrumbs_asset_proxy(asset_path):
"""Serve allowlisted shared-ui assets through momo-pro's own origin."""
normalized_path = _normalize_webcrumbs_asset_path(asset_path)
if not normalized_path or not WEBCRUMBS_ENABLED:
return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')
if not WEBCRUMBS_ASSET_UPSTREAM_URL:
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
return _webcrumbs_fallback_loader_response('missing-upstream')
return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')
upstream_url = f"{WEBCRUMBS_ASSET_UPSTREAM_URL}/{quote(normalized_path, safe='/@._-')}"
try:
upstream_response = requests.get(upstream_url, timeout=(2, 8))
except requests.RequestException as exc:
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
sys_log.info("[Webcrumbs] Loader upstream unavailable; serving local fallback")
return _webcrumbs_fallback_loader_response('upstream-unavailable')
sys_log.warning(f"[Webcrumbs] Asset proxy failed: {normalized_path} -> {exc}")
return Response('Webcrumbs asset upstream unavailable', status=502, mimetype='text/plain')
if upstream_response.status_code >= 400:
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
sys_log.info(
"[Webcrumbs] Loader upstream returned %s; serving local fallback",
upstream_response.status_code,
)
return _webcrumbs_fallback_loader_response(f'upstream-{upstream_response.status_code}')
sys_log.warning(f"[Webcrumbs] Asset proxy returned {upstream_response.status_code}: {normalized_path}")
return Response('Webcrumbs asset upstream returned error', status=upstream_response.status_code, mimetype='text/plain')
content_type = upstream_response.headers.get('Content-Type')
if not content_type:
content_type = mimetypes.guess_type(normalized_path)[0] or 'application/octet-stream'
response = Response(upstream_response.content, status=upstream_response.status_code)
response.headers['Content-Type'] = content_type
response.headers['Cache-Control'] = 'public, max-age=300'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
return response
@system_public_bp.route('/metrics')
def prometheus_metrics():
"""Prometheus 指標端點 - 供 Prometheus 抓取監控資料"""
try:
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Gauge, CollectorRegistry
from services.ai_automation_metrics import snapshot as ai_metrics_snapshot
registry = CollectorRegistry()
app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry)
app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1)
app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry)
db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry)
try:
db = DatabaseManager()
with db.engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_status.set(1)
app_health.set(1)
except Exception:
db_status.set(0)
app_health.set(0)
session = None
try:
db = DatabaseManager()
session = db.get_session()
_set_database_record_counts(registry, Gauge, session)
except Exception as e:
sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}")
finally:
if session is not None:
session.close()
try:
_register_ai_automation_metrics(registry, Gauge, ai_metrics_snapshot())
except Exception as e:
sys_log.warning(f"[Metrics] 無法取得 AI 自動化指標: {e}")
return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST)
except ImportError:
metrics_text = f"""# HELP momo_app_health 應用程式健康狀態
# TYPE momo_app_health gauge
momo_app_health 1
# HELP momo_app_info 應用程式資訊
# TYPE momo_app_info gauge
momo_app_info{{version="{SYSTEM_VERSION}",database_type="{DATABASE_TYPE}"}} 1
"""
return Response(metrics_text, mimetype='text/plain; charset=utf-8')
except Exception as e:
sys_log.error(f"[Metrics] 指標生成錯誤: {e}")
return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500
def _labels_to_dict(labels):
return dict(labels)
def _set_database_record_counts(registry, gauge_cls, session):
"""Register DB counts without selecting drift-prone sales columns."""
product_count = gauge_cls('momo_products_total', '商品總數', registry=registry)
product_count.set(session.query(Product).count())
price_record_count = gauge_cls('momo_price_records_total', '價格記錄總數', registry=registry)
price_record_count.set(session.query(PriceRecord).count())
# V-Fix: realtime_sales_monthly 線上欄位曾與 ORM 不同步metrics 只需要總筆數。
sales_count = gauge_cls('momo_sales_records_total', '業績資料總數', registry=registry)
sales_total = session.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar() or 0
sales_count.set(sales_total)
def _register_ai_automation_metrics(registry, gauge_cls, metrics_snapshot):
"""Export dependency-free AI metrics into a per-request Prometheus registry."""
gauges = {}
def get_gauge(name, help_text, label_names):
if name not in gauges:
gauges[name] = gauge_cls(name, help_text, label_names, registry=registry)
return gauges[name]
definitions = {
"event_router_dispatch_total": (
"momo_ai_event_router_dispatch_total",
"EventRouter dispatch count",
["event_type", "outcome", "tier"],
),
"event_router_safe_action_total": (
"momo_ai_event_router_safe_action_total",
"EventRouter L2 safe action count",
["action", "status"],
),
"event_router_replay_total": (
"momo_ai_event_router_replay_total",
"EventRouter queued Telegram replay count",
["status"],
),
"autoheal_action_total": (
"momo_ai_autoheal_action_total",
"AutoHeal action count",
["action", "error_type", "result"],
),
}
counter_samples = {
("event_router_dispatch_total", (
("event_type", "baseline"),
("outcome", "none"),
("tier", "baseline"),
)): 0,
("event_router_safe_action_total", (
("action", "baseline"),
("status", "none"),
)): 0,
("event_router_replay_total", (
("status", "none"),
)): 0,
("autoheal_action_total", (
("action", "baseline"),
("error_type", "none"),
("result", "none"),
)): 0,
}
counter_samples.update(metrics_snapshot.get("counters", {}))
for (metric, labels), value in counter_samples.items():
if metric not in definitions:
continue
name, help_text, label_names = definitions[metric]
gauge = get_gauge(name, help_text, label_names)
label_values = _labels_to_dict(labels)
gauge.labels(**{name: label_values.get(name, "unknown") for name in label_names}).set(value)
latency_defs = {
"event_router_latency_ms": (
"momo_ai_event_router_latency_ms",
"EventRouter dispatch latency in milliseconds",
["event_type", "tier"],
),
"autoheal_duration_ms": (
"momo_ai_autoheal_duration_ms",
"AutoHeal action duration in milliseconds",
["action", "error_type"],
),
}
latency_samples = {
("event_router_latency_ms", (
("event_type", "baseline"),
("tier", "baseline"),
)): {"count": 0, "sum": 0, "max": 0},
("autoheal_duration_ms", (
("action", "baseline"),
("error_type", "none"),
)): {"count": 0, "sum": 0, "max": 0},
}
latency_samples.update(metrics_snapshot.get("latency", {}))
for (metric, labels), values in latency_samples.items():
if metric not in latency_defs:
continue
name, help_text, label_names = latency_defs[metric]
label_values = _labels_to_dict(labels)
for suffix in ("count", "sum", "max"):
gauge = get_gauge(f"{name}_{suffix}", f"{help_text} ({suffix})", label_names)
gauge.labels(**{name: label_values.get(name, "unknown") for name in label_names}).set(values.get(suffix, 0))
@system_public_bp.route('/settings')
def settings():
"""分類設定頁面"""
categories = load_categories()
return render_template('settings.html',
categories=categories,
public_url=public_url,
system_version=SYSTEM_VERSION)
@system_public_bp.route('/system_settings')
def system_settings_page():
"""系統設定與匯入頁面"""
return render_template(
'system_settings.html',
system_version=SYSTEM_VERSION,
active_page='system_settings',
)
@system_public_bp.route('/ai_automation_smoke')
@login_required
def ai_automation_smoke_page():
"""AI 自動化閉環 smoke dashboard."""
return render_template(
'ai_automation_smoke.html',
system_version=SYSTEM_VERSION,
active_page='ai_automation_smoke',
)
@system_public_bp.route('/api/ai-automation/smoke')
@login_required
def ai_automation_smoke_api():
"""Read-only smoke status for the four-agent AI automation control plane."""
from services.ai_automation_smoke_service import collect_ai_automation_smoke
return jsonify(collect_ai_automation_smoke())
@system_public_bp.route('/api/ai-automation/smoke/history/export')
@login_required
def ai_automation_smoke_history_export():
"""Export compact smoke history JSONL."""
from services.ai_automation_smoke_service import export_smoke_history_jsonl
export = export_smoke_history_jsonl()
response = Response(export["content"], mimetype='application/x-ndjson; charset=utf-8')
response.headers["Content-Disposition"] = "attachment; filename=ai_automation_smoke_history.jsonl"
response.headers["X-Smoke-History-Count"] = str(export["count"])
return response
@system_public_bp.route('/api/ai-automation/smoke/history/clear', methods=['POST'])
@login_required
def ai_automation_smoke_history_clear():
"""Clear local compact smoke history JSONL."""
from services.ai_automation_smoke_service import clear_smoke_history
return jsonify(clear_smoke_history())
@system_public_bp.route('/api/ai-automation/smoke/daily-summary/send', methods=['POST'])
@login_required
def ai_automation_smoke_daily_summary_send():
"""Send compact smoke trend daily summary to Telegram."""
from services.ai_automation_smoke_service import send_smoke_daily_summary
result = send_smoke_daily_summary()
status_code = 200 if result.get("status") == "sent" else 502
return jsonify(result), status_code
@system_public_bp.route('/logs')
def show_logs():
return render_template('logs.html', active_page='logs')
@system_public_bp.route('/api/logs')
def get_logs_api():
if os.path.exists(LOG_FILE_PATH):
try:
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
return jsonify({"logs": "".join(f.readlines()[-60:])})
except Exception as e:
sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
return jsonify({"logs": "讀取日誌異常"})
return jsonify({"logs": "等待系統啟動中..."})
@system_public_bp.route('/api/backup', methods=['POST'])
@login_required
def trigger_backup():
"""API: 觸發系統完整備份"""
try:
sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...")
backup_dir = os.path.join(BASE_DIR, 'backups')
if not os.path.exists(backup_dir):
os.makedirs(backup_dir)
timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
zip_filepath = os.path.join(backup_dir, zip_filename)
with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(BASE_DIR):
dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]
for file in files:
if file == zip_filename:
continue
if file.endswith('.pyc') or file.endswith('.DS_Store'):
continue
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, BASE_DIR)
zipf.write(file_path, arcname)
sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}")
download_url = url_for('system_public.download_backup', filename=zip_filename)
return jsonify({
"status": "success",
"message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...",
"download_url": download_url
})
except Exception as e:
sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@system_public_bp.route('/api/backup/download/<path:filename>')
@login_required
def download_backup(filename):
"""API: 下載備份檔案(已加入路徑遍歷防護)"""
try:
backup_dir = os.path.join(BASE_DIR, 'backups')
safe_path = safe_join(backup_dir, filename)
if not safe_path.exists():
sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
return jsonify({'error': '檔案不存在'}), 404
if not safe_path.is_file():
sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
return jsonify({'error': '非法路徑'}), 400
return send_from_directory(backup_dir, safe_path.name, as_attachment=True)
except ValueError as e:
sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
return jsonify({'error': '非法路徑'}), 400
except Exception as e:
sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
return jsonify({'error': '下載失敗'}), 500