From c38dc80f58355888bfc1d7004519c8a8a030efa1 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 18 May 2026 23:25:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=96=B0=E5=A2=9E=20API=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A3=80=E6=B5=8B=E9=A1=B5=EF=BC=9A=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=A3=80=E6=B5=8B=20Supabase/Open-Meteo/METAR/KNMI/MA?= =?UTF-8?q?DIS/Telegram=20=E8=BF=9E=E9=80=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/api/ops/health-check/route.ts | 16 ++++ frontend/app/ops/health/page.tsx | 10 ++ frontend/app/page.tsx | 2 +- .../SubscriptionHelpClient.tsx | 4 +- frontend/components/account/AccountCenter.tsx | 8 +- .../dashboard/ScanTerminalDashboard.tsx | 54 +++++++++++ .../ops/health/HealthPageClient.tsx | 92 +++++++++++++++++++ .../components/ops/layout/AdminSidebar.tsx | 1 + .../subscription/UnlockProOverlay.tsx | 4 +- web/routers/ops.py | 6 ++ web/services/ops_api.py | 71 ++++++++++++++ 11 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 frontend/app/api/ops/health-check/route.ts create mode 100644 frontend/app/ops/health/page.tsx create mode 100644 frontend/components/ops/health/HealthPageClient.tsx diff --git a/frontend/app/api/ops/health-check/route.ts b/frontend/app/api/ops/health-check/route.ts new file mode 100644 index 00000000..25642c28 --- /dev/null +++ b/frontend/app/api/ops/health-check/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" }); + const raw = await res.text(); + const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); + return applyAuthResponseCookies(response, auth.response); + } catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Health check failed" }); } +} diff --git a/frontend/app/ops/health/page.tsx b/frontend/app/ops/health/page.tsx new file mode 100644 index 00000000..c908926b --- /dev/null +++ b/frontend/app/ops/health/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { HealthPageClient } from "@/components/ops/health/HealthPageClient"; + +export const metadata: Metadata = { title: "API 状态 — PolyWeather Ops" }; + +export default async function HealthPage() { + await requireOpsAdmin("/ops/health"); + return ; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index af0bebb0..02455197 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -4,7 +4,7 @@ import { DashboardEntry } from "@/components/dashboard/DashboardEntry"; export const metadata: Metadata = { title: "PolyWeather - Global Weather Intelligence Map", description: - "PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and history reconciliation.", + "PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and AI weather decision cards.", }; export default function HomePage() { diff --git a/frontend/app/subscription-help/SubscriptionHelpClient.tsx b/frontend/app/subscription-help/SubscriptionHelpClient.tsx index 2dce2f6b..d587dd0e 100644 --- a/frontend/app/subscription-help/SubscriptionHelpClient.tsx +++ b/frontend/app/subscription-help/SubscriptionHelpClient.tsx @@ -21,8 +21,8 @@ const FAQ_ITEMS = [ { q_zh: "Pro 包含哪些功能?", q_en: "What features does Pro include?", - a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。", - a_en: "Unlocks: intraday METAR rule analysis (including peak window), historical reconciliation + future-date analysis, and cross-platform smart weather push.", + a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。", + a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.", }, { q_zh: "当前订阅价格是多少?", diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 318ea400..442d885d 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -707,8 +707,8 @@ export function AccountCenter() { weatherEngine: isEn ? "Weather Engine" : "气象引擎", intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析", historyFuture: isEn - ? "Historical + Future-date Analysis" - : "历史对账 + 未来日期分析", + ? "Future-date + Decision Card Analysis" + : "未来日期分析 + 城市决策卡", smartPush: isEn ? "Cross-platform Smart Weather Push" : "全平台智能气象查询", @@ -814,8 +814,8 @@ export function AccountCenter() { noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", trialEndsSoonTitle: isEn ? "Trial ending soon" : "试用即将结束", trialEndsSoonBody: isEn - ? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and history." - : "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和历史对账。", + ? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and decision cards." + : "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和城市决策卡。", trialExpiredTitle: isEn ? "Trial ended" : "试用已结束", trialExpiredBody: isEn ? "Your trial access has ended. Renew with Pro to restore full access." diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 2c7b6d8d..f57cbbcc 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -259,6 +259,21 @@ function ScanTerminalScreen() { : selectedRow; const scanStatus = terminalData?.status || "ready"; const staleReason = terminalData?.stale_reason || null; + const proPreviewItems = isEn + ? [ + "Intraday METAR rule analysis", + "Multi-model high-temp forecast", + "Real-time observation deviation", + "Future-date decision cards", + "Telegram group price $5", + ] + : [ + "日内机场报文规则分析", + "多模型高温预测", + "实时观测偏差", + "未来日期城市决策卡", + "Telegram 群内价 5U", + ]; useEffect(() => { if (!activeDetailRow) return; @@ -405,6 +420,45 @@ function ScanTerminalScreen() { userLocalTime={userLocalTime} /> + {!isPro ? ( +
+
+ {isEn ? "What Pro unlocks" : "开通 Pro 后可看到"} + + {isEn + ? "Full weather decision context, not just the public map." + : "不只是公开地图,而是完整天气交易辅助信息。"} + +

+ {isEn + ? "Guests and free users can browse the map. Pro adds live evidence, model deltas and city-level decision cards for current and future dates." + : "游客和免费用户可浏览地图;Pro 会补齐实时证据、模型偏差和当前/未来日期的城市决策卡。"} +

+
+ + {proAccess.authenticated ? ( + + ) : ( + + {isEn ? "Sign in for Pro" : "登录查看 Pro"} + + )} +
+ ) : null} +
diff --git a/frontend/components/ops/health/HealthPageClient.tsx b/frontend/components/ops/health/HealthPageClient.tsx new file mode 100644 index 00000000..7f1ac10e --- /dev/null +++ b/frontend/components/ops/health/HealthPageClient.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +type ServiceResult = { ok: boolean; status?: number; latency_ms?: number; error?: string }; +type HealthPayload = { ok: boolean; checked_at: string; services: Record }; + +const LABELS: Record = { + supabase: "Supabase", + open_meteo: "Open-Meteo", + metar: "METAR (AviationWeather)", + knmi: "KNMI (Amsterdam)", + madis: "MADIS (NOAA)", + telegram: "Telegram Bot", +}; + +function StatusIcon({ svc }: { svc: ServiceResult }) { + if (svc.ok) return ; + if (svc.error && svc.error.includes("not configured")) return ; + return ; +} + +function StatusText({ svc }: { svc: ServiceResult }) { + if (svc.ok) return {svc.latency_ms}ms; + if (svc.error && svc.error.includes("not configured")) return 未配置; + return {svc.error ?? "连接失败"}; +} + +export function HealthPageClient() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const load = async () => { + setLoading(true); + setError(""); + try { + const res = await fetch("/api/ops/health-check", { cache: "no-store" }); + if (!res.ok) { setError(`HTTP ${res.status}`); setLoading(false); return; } + setData(await res.json()); + } catch (e) { setError(String(e).slice(0, 200)); } + setLoading(false); + }; + + useEffect(() => { void load(); }, []); + + if (loading) return
检测中...
; + if (error) return
加载失败: {error}
; + if (!data) return
无数据
; + + const services = Object.entries(data.services); + const okCount = services.filter(([, v]) => v.ok).length; + + return ( +
+
+

+ API 状态{" "} + + {data.ok ? "全部正常" : `${okCount}/${services.length} 正常`} + +

+
+ {data.checked_at?.slice(11, 19) ?? ""} + +
+
+ +
+ {services.map(([key, svc]) => ( + + +
+ {LABELS[key] ?? key} + +
+
+ {svc.status ? HTTP {svc.status} : null} + +
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/components/ops/layout/AdminSidebar.tsx b/frontend/components/ops/layout/AdminSidebar.tsx index 30dbe37f..fe890b60 100644 --- a/frontend/components/ops/layout/AdminSidebar.tsx +++ b/frontend/components/ops/layout/AdminSidebar.tsx @@ -21,6 +21,7 @@ const navGroups = [ label: "监控", items: [ { href: "/ops/overview", icon: LayoutDashboard, label: "总览" }, + { href: "/ops/health", icon: Activity, label: "API 状态" }, { href: "/ops/system", icon: Cpu, label: "系统状态" }, { href: "/ops/training", icon: Database, label: "训练数据" }, { href: "/ops/analytics", icon: BarChart3, label: "转化分析" }, diff --git a/frontend/components/subscription/UnlockProOverlay.tsx b/frontend/components/subscription/UnlockProOverlay.tsx index e86a6c73..c4f385ca 100644 --- a/frontend/components/subscription/UnlockProOverlay.tsx +++ b/frontend/components/subscription/UnlockProOverlay.tsx @@ -60,13 +60,13 @@ const FEATURES = { "zh-CN": [ "市场扫描台 + V4-Pro 深度复核", "今日日内机场报文规则分析(含高温时段)", - "历史对账 + 未来日期分析", + "未来日期分析 + 城市决策卡", "全平台智能气象推送", ], "en-US": [ "Market Scan Terminal + V4-Pro review", "Intraday METAR rule-based analysis with peak-time window", - "Historical reconciliation + future-date analysis", + "Future-date analysis + city decision cards", "Cross-platform alerts", ], }; diff --git a/web/routers/ops.py b/web/routers/ops.py index d1e680d1..28fd8de8 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -7,6 +7,7 @@ from web.services.ops_api import ( extend_ops_subscription, get_ops_analytics_funnel, get_ops_config, + get_ops_health_check, get_ops_logs, get_ops_truth_history, get_ops_weekly_leaderboard, @@ -136,3 +137,8 @@ async def ops_logs( lines: int = 100, ): return get_ops_logs(request, level=level, lines=lines) + + +@router.get("/api/ops/health-check") +async def ops_health_check(request: Request): + return get_ops_health_check(request) diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 3ddfa7a1..4c887555 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -433,3 +433,74 @@ def get_ops_logs( "lines": log_lines[-safe_lines:], "total": len(log_lines), } + + +def get_ops_health_check(request: Request) -> dict[str, Any]: + _require_ops(request) + import os, requests as _r, time as _time + + results: dict[str, dict] = {} + timeout = 3 + + # Supabase + supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") + supabase_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() + if supabase_url and supabase_key: + try: + r = _r.get(f"{supabase_url}/rest/v1/", headers={"apikey": supabase_key, "Authorization": f"Bearer {supabase_key}"}, timeout=timeout) + results["supabase"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round(r.elapsed.total_seconds() * 1000)} + except Exception as e: + results["supabase"] = {"ok": False, "error": str(e)[:100]} + else: + results["supabase"] = {"ok": False, "error": "not configured"} + + # Open-Meteo + try: + t0 = _time.perf_counter() + r = _r.get("https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&daily=temperature_2m_max&timezone=auto&forecast_days=1", timeout=timeout) + results["open_meteo"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} + except Exception as e: + results["open_meteo"] = {"ok": False, "error": str(e)[:100]} + + # METAR (aviationweather) + try: + t0 = _time.perf_counter() + r = _r.get("https://aviationweather.gov/api/data/metar?ids=KJFK&format=json", timeout=timeout) + results["metar"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} + except Exception as e: + results["metar"] = {"ok": False, "error": str(e)[:100]} + + # KNMI + knmi_key = str(os.getenv("KNMI_API_KEY") or "").strip() + if knmi_key: + try: + t0 = _time.perf_counter() + r = _r.get("https://api.dataplatform.knmi.nl/open-data/v1/datasets", headers={"Authorization": knmi_key}, timeout=timeout) + results["knmi"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} + except Exception as e: + results["knmi"] = {"ok": False, "error": str(e)[:100]} + else: + results["knmi"] = {"ok": False, "error": "not configured"} + + # MADIS (NOAA) + try: + t0 = _time.perf_counter() + r = _r.get("https://madis-data.ncep.noaa.gov/madisPublic1/data/LDAD/hfmetar/netCDF/", timeout=timeout) + results["madis"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} + except Exception as e: + results["madis"] = {"ok": False, "error": str(e)[:100]} + + # Telegram Bot + bot_token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip() + if bot_token: + try: + t0 = _time.perf_counter() + r = _r.get(f"https://api.telegram.org/bot{bot_token}/getMe", timeout=timeout) + results["telegram"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} + except Exception as e: + results["telegram"] = {"ok": False, "error": str(e)[:100]} + else: + results["telegram"] = {"ok": False, "error": "not configured"} + + all_ok = all(v.get("ok") for v in results.values()) + return {"ok": all_ok, "checked_at": __import__("datetime").datetime.utcnow().isoformat() + "Z", "services": results}