From ca72072da084d6ad13d7a9fe41b09f3f3f185eec Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 31 May 2026 01:56:08 +0800 Subject: [PATCH] Optimize terminal startup and deployment smoke checks --- deploy.sh | 2 +- frontend/app/api/auth/me/route.ts | 37 +++++++- frontend/app/layout.tsx | 4 +- frontend/app/page.tsx | 4 +- frontend/app/terminal/page.tsx | 13 ++- .../account/__tests__/paymentSecurity.test.ts | 10 ++- .../account/__tests__/paymentShell.test.ts | 7 ++ frontend/components/account/usePaymentFlow.ts | 44 +++++++--- frontend/components/auth/LoginClient.tsx | 4 +- .../dashboard/ScanTerminalDashboard.tsx | 23 ++++- .../scan-terminal/CitySelectorDropdown.tsx | 7 +- .../LiveTemperatureThresholdChart.tsx | 14 ++- .../TemperatureChartCanvasFallback.tsx | 35 ++++++++ .../__tests__/terminalGridPolicy.test.ts | 47 ++++++++++ .../scan-terminal/use-scan-terminal-query.ts | 19 +++- .../__tests__/landingPricingReferral.test.ts | 5 ++ .../ops/__tests__/opsConfig.test.ts | 33 +++++++ .../ops/config/ConfigPageClient.tsx | 10 +++ tests/test_deployment_runtime_config.py | 3 +- tests/test_web_observability.py | 25 ++++++ web/services/city_api.py | 88 +++++++++++++++++-- 21 files changed, 393 insertions(+), 41 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/TemperatureChartCanvasFallback.tsx create mode 100644 frontend/components/ops/__tests__/opsConfig.test.ts diff --git a/deploy.sh b/deploy.sh index e57b07b7..22643c76 100644 --- a/deploy.sh +++ b/deploy.sh @@ -146,11 +146,11 @@ fi warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3 warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2 +warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2 warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2 FAILED=0 smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1 -smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3 || FAILED=1 smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1 smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1 diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 854637b6..7777c399 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -4,7 +4,6 @@ import { buildBackendRequestHeaders, } from "@/lib/backend-auth"; import { - buildProxyExceptionResponse, buildUpstreamErrorResponse, } from "@/lib/api-proxy"; import { @@ -136,6 +135,27 @@ function subscriptionRequiredAuthProfileResponse({ return applyAuthResponseCookies(inactive, response); } +function unauthenticatedAuthProfileResponse({ + reason, + response, +}: { + reason: string; + response: NextResponse | null; +}) { + const anonymous = NextResponse.json( + { + authenticated: false, + subscription_active: false, + points: 0, + degraded_auth_profile: true, + degraded_reason: reason, + }, + { headers: { "Cache-Control": "no-store" } }, + ); + clearEntitlementSnapshotCookie(anonymous); + return applyAuthResponseCookies(anonymous, response); +} + function snapshotAuthProfileResponse({ email, reason, @@ -377,8 +397,19 @@ export async function GET(req: NextRequest) { userId: identity.userId, }); } - return buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch auth profile", + const snapshotPayload = entitlementSnapshotToAuthPayload( + readEntitlementSnapshot(req), + ); + if (snapshotPayload) { + const snapshotResponse = NextResponse.json({ + ...snapshotPayload, + entitlement_snapshot_reason: "exception_snapshot", + }); + return applyAuthResponseCookies(snapshotResponse, auth?.response || null); + } + return unauthenticatedAuthProfileResponse({ + reason: String(error), + response: auth?.response || null, }); } } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index c18cfc24..4eca9acc 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -23,7 +23,7 @@ export const metadata: Metadata = { template: "%s | PolyWeather", }, description: - "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards. Real-time observations for 51 global cities.", + "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context. Real-time observations for 51 global cities.", manifest: "/site.webmanifest", metadataBase: new URL("https://polyweather.top"), alternates: { @@ -34,7 +34,7 @@ export const metadata: Metadata = { siteName: "PolyWeather", title: "PolyWeather | Institutional Weather Signal Intelligence", description: - "Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, AI decision cards. 51 cities, real-time.", + "Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, structured decision context. 51 cities, real-time.", url: "https://polyweather.top", images: [ { diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index dea2db0c..6331f530 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,7 +5,7 @@ import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLand export const metadata: Metadata = { title: "PolyWeather | Institutional Weather Signal Intelligence", description: - "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.", + "PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.", other: { preconnect: "https://api.polyweather.top", }, @@ -37,7 +37,7 @@ export default async function HomePage({ "@type": "WebApplication", name: "PolyWeather", description: - "Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.", + "Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.", url: "https://polyweather.top", applicationCategory: "BusinessApplication", operatingSystem: "Web", diff --git a/frontend/app/terminal/page.tsx b/frontend/app/terminal/page.tsx index 6ca280d9..55715d6b 100644 --- a/frontend/app/terminal/page.tsx +++ b/frontend/app/terminal/page.tsx @@ -1,5 +1,16 @@ import type { Metadata } from "next"; -import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard"; +import dynamic from "next/dynamic"; +import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton"; + +const ScanTerminalDashboard = dynamic( + () => + import("@/components/dashboard/ScanTerminalDashboard").then( + (mod) => mod.ScanTerminalDashboard, + ), + { + loading: () => , + }, +); export const metadata: Metadata = { title: "PolyWeather Terminal | Paid Product", diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index 7fb13a57..1a1f42a4 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -149,10 +149,16 @@ export function runTests() { "/api/auth/me must verify bearer tokens directly and return a degraded authenticated profile when the backend auth profile is transiently unavailable", ); assert( - authMeRouteSource.indexOf("const bearerIdentity = await getVerifiedBearerIdentity(req)") < - authMeRouteSource.indexOf("return buildProxyExceptionResponse(error"), + authMeRouteSource.includes("const identity = await getBearerIdentityOnce();") && + !authMeRouteSource.includes("return buildProxyExceptionResponse(error"), "/api/auth/me must try bearer identity fallback before returning a proxy exception", ); + assert( + authMeRouteSource.includes("exception_snapshot") && + authMeRouteSource.includes("unauthenticatedAuthProfileResponse") && + !authMeRouteSource.includes("return buildProxyExceptionResponse(error"), + "/api/auth/me must serve a snapshot or anonymous auth profile before surfacing proxy exceptions to long-lived clients", + ); for (const route of [ "app/api/ops/analytics/funnel/route.ts", "app/api/ops/config/route.ts", diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index 3e5f13d1..b424658f 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -372,4 +372,11 @@ export function runTests() { .length >= 3, "manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON", ); + assert( + paymentFlowSource.includes("verifyPaymentAuthReady") && + paymentFlowSource.indexOf("await verifyPaymentAuthReady()") < + paymentFlowSource.indexOf("resolvePaymentProvider(") && + paymentFlowSource.includes("auth_confirmed_at"), + "wallet checkout must confirm a fresh Supabase bearer before opening wallet prompts or creating payment intents", + ); } diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 869d654f..14069fcb 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -183,6 +183,35 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { trackAppEvent("payment_success", payload); }, []); + const verifyPaymentAuthReady = useCallback(async () => { + const accessToken = await getValidAccessToken(); + const authHeaders: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; + const authRes = await fetch("/api/auth/me", { + cache: "no-store", + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }, + }); + if (!authRes.ok) { + const raw = (await authRes.text().catch(() => "")).slice(0, 240); + throw new Error( + isEn + ? `Authentication check failed before payment (${authRes.status}). ${raw}` + : `支付前登录态校验失败 (${authRes.status})。${raw}`, + ); + } + const profile = (await authRes.json()) as AuthMeResponse; + if (profile.authenticated !== true) { + throw new Error(copy.loginBeforePay); + } + return { + authHeaders, + auth_confirmed_at: new Date().toISOString(), + profile, + }; + }, [copy.loginBeforePay, getValidAccessToken, isEn]); + const availableChainList: PaymentChainOption[] = useMemo(() => { const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : []; const clean = configured @@ -401,6 +430,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { setPaymentBusy(true); let approvedInThisRun = false; try { + const authReady = await verifyPaymentAuthReady(); + const authHeaders = authReady.authHeaders; const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey); const eth = providerSelection.provider; const activeAccounts = await requestWalletWithTimeout( @@ -418,18 +449,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { setSelectedWallet(payingWallet); setProviderMode(providerSelection.mode); - let accessToken: string; - try { accessToken = await getValidAccessToken(); } - catch (tokenErr) { - setPaymentError(normalizePaymentError(tokenErr).message); - setPaymentBusy(false); - return; - } - const authHeaders: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }; - const latestConfig = await fetchLatestPaymentConfig(authHeaders, true); if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady); const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137); @@ -480,6 +499,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { source: "account_center", frontend_host: currentPaymentHost || null, account_email: backend?.email || null, + auth_confirmed_at: authReady.auth_confirmed_at, }, }), }); diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index 1e63cd80..f27bde30 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -108,8 +108,8 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { ? "By proceeding, you agree to the Privacy Policy and Terms & Conditions." : "继续操作即代表您同意隐私政策与服务条款。", desc: isEn - ? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses." - : "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。", + ? "Access robust METAR observations, advanced DEB forecast blends, and structured decision context for weather risk analysis." + : "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。", trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖", } as const; const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting; diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index be8dec7f..321242d8 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -1,6 +1,7 @@ "use client"; import clsx from "clsx"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { Activity, @@ -44,7 +45,6 @@ import { ScanTerminalLoadingScreen } from "@/components/dashboard/scan-terminal/ import { scanRootClass } from "@/components/dashboard/scan-root-styles"; import { useRelativeTime } from "@/hooks/useRelativeTime"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; -import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard"; import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard"; import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable"; @@ -62,6 +62,20 @@ import { } from "@/components/dashboard/scan-terminal/city-fallback-rows"; import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; +const TrainingDashboard = dynamic( + () => + import("@/components/dashboard/scan-terminal/TrainingDashboard").then( + (mod) => mod.TrainingDashboard, + ), + { + loading: () => ( +
+ Loading analytics... +
+ ), + }, +); + function createEmptyAccess(loading = true): ProAccessState { return { loading, @@ -1037,6 +1051,9 @@ function ScanTerminalScreen() { hydrated && (proAccess.authenticated || canUseLocalFullAccess); const isPro = hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess); + const accessDecisionPending = + !hydrated || (proAccess.loading && !canUseLocalFullAccess); + const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro); const userLocalTime = useUserLocalClock(); const { themeMode } = useScanTerminalTheme(); const [selectedRegionKey, setSelectedRegionKey] = useState("all"); @@ -1272,7 +1289,7 @@ function ScanTerminalScreen() { }, []); const generatedText = useRelativeTime(terminalData?.generated_at ?? null); - if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) { + if (accessDecisionPending) { return ( (null); const inputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); + const deferredSearchQuery = useDeferredValue(searchQuery); const [activeTab, setActiveTab] = useState("all"); const [viewportNudgeY, setViewportNudgeY] = useState(0); @@ -170,7 +171,7 @@ export function CitySelectorDropdown({ // Filter rows const filteredRows = useMemo(() => { - const q = searchQuery.toLowerCase().trim(); + const q = deferredSearchQuery.toLowerCase().trim(); return rows.filter((row) => { // 1. Region filter if (activeTab !== "all") { @@ -195,7 +196,7 @@ export function CitySelectorDropdown({ .map((s) => s!.toLowerCase()); return haystack.some((s) => s.includes(q)); }); - }, [rows, searchQuery, activeTab]); + }, [rows, deferredSearchQuery, activeTab]); useEffect(() => { let frame = 0; diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index af0fc9a4..93477c4f 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -1,12 +1,13 @@ "use client"; import clsx from "clsx"; +import dynamic from "next/dynamic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { ModelCurvesSummary } from "@/components/dashboard/scan-terminal/ModelCurvesSummary"; -import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas"; +import { TemperatureChartCanvasFallback } from "@/components/dashboard/scan-terminal/TemperatureChartCanvasFallback"; import { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails"; import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars"; import { rowName } from "@/components/dashboard/scan-terminal/utils"; @@ -56,6 +57,17 @@ const PEAK_GLOW_BADGE_CLASS = { const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000; +const TemperatureChartCanvas = dynamic( + () => + import("@/components/dashboard/scan-terminal/TemperatureChartCanvas").then( + (mod) => mod.TemperatureChartCanvas, + ), + { + ssr: false, + loading: () => , + }, +); + function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) { if (state === "watch") return isEn ? "Watch" : "关注"; if (state === "near_peak") return isEn ? "Near peak" : "接近峰值"; diff --git a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvasFallback.tsx b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvasFallback.tsx new file mode 100644 index 00000000..d96d9b0f --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvasFallback.tsx @@ -0,0 +1,35 @@ +export function TemperatureChartCanvasFallback({ compact }: { compact?: boolean }) { + const horizontalLines = compact ? 5 : 7; + const verticalLines = compact ? 5 : 8; + const minChartHeight = compact === false ? 220 : 120; + + return ( +
+
+ {Array.from({ length: horizontalLines }).map((_, index) => ( + + ))} + {Array.from({ length: verticalLines }).map((_, index) => ( + + ))} +
+
+
+ + 加载图表 +
+
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts index 781d39c2..dcd905c7 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -20,6 +20,10 @@ export function runTests() { path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"), "utf8", ); + const terminalPageSource = fs.readFileSync( + path.join(projectRoot, "app", "terminal", "page.tsx"), + "utf8", + ); const chartCanvasSource = fs.readFileSync( path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"), "utf8", @@ -28,6 +32,10 @@ export function runTests() { path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"), "utf8", ); + const scanQuerySource = fs.readFileSync( + path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"), + "utf8", + ); assert( dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"), @@ -75,6 +83,26 @@ export function runTests() { citySelectorSource.includes("getBoundingClientRect()"), "city selector dropdown must nudge itself inside the viewport when opened from top-row chart cards", ); + assert( + terminalPageSource.includes("dynamic(") && + terminalPageSource.includes('import("@/components/dashboard/ScanTerminalDashboard")') && + terminalPageSource.includes("DashboardShellSkeleton") && + !terminalPageSource.includes('import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";'), + "terminal route must dynamically load the heavy dashboard behind the shell skeleton", + ); + assert( + dashboardSource.includes('from "next/dynamic"') && + dashboardSource.includes('import("@/components/dashboard/scan-terminal/TrainingDashboard")') && + !dashboardSource.includes('import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";'), + "terminal dashboard must lazy-load the training analytics tab so Recharts stays out of the default terminal path", + ); + assert( + chartSource.includes('from "next/dynamic"') && + chartSource.includes("TemperatureChartCanvasFallback") && + chartSource.includes("import(\"@/components/dashboard/scan-terminal/TemperatureChartCanvas\")") && + !chartSource.includes('import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";'), + "terminal temperature charts must lazy-load the Recharts canvas behind a lightweight fallback", + ); assert( chartSource.includes("setLiveTemp(null);") && chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"), @@ -109,6 +137,25 @@ export function runTests() { dashboardSource.includes("[rows, deferredSearchQuery]"), "terminal search must defer expensive row filtering so typing stays responsive", ); + assert( + scanQuerySource.includes("MAX_STALE_SCAN_CACHE_MS") && + scanQuerySource.includes("allowStale") && + scanQuerySource.includes("setCachedRows(readScanCache(tradingRegion || \"\", { allowStale: true }))"), + "terminal data hook must render stale scan rows immediately while revalidating the first-screen API", + ); + assert( + citySelectorSource.includes("useDeferredValue") && + citySelectorSource.includes("deferredSearchQuery") && + citySelectorSource.includes("[rows, deferredSearchQuery, activeTab]"), + "city selector search must defer expensive dropdown filtering so top-row selection stays responsive", + ); + assert( + dashboardSource.includes("accessDecisionPending") && + dashboardSource.includes("shouldShowPaywall") && + dashboardSource.indexOf("if (accessDecisionPending)") < + dashboardSource.indexOf("if (shouldShowPaywall)"), + "terminal must keep showing verification while access is undecided instead of flashing the paywall", + ); assert( dashboardSource.includes('trackAppEvent("enter_terminal"') && dashboardSource.includes('entry: "terminal"'), diff --git a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts index c83e345c..bcab739c 100644 --- a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts +++ b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts @@ -17,17 +17,23 @@ import type { ScanTerminalResponse } from "@/lib/dashboard-types"; const SCAN_CACHE_PREFIX = "polyweather_scan_v2"; const SCAN_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.scanRows; +const MAX_STALE_SCAN_CACHE_MS = 6 * 60 * 60 * 1000; function scanCacheKey(tradingRegion: string): string { return `${SCAN_CACHE_PREFIX}:${tradingRegion || "all"}`; } -function readScanCache(tradingRegion: string): ScanTerminalResponse | null { +function readScanCache( + tradingRegion: string, + options?: { allowStale?: boolean }, +): ScanTerminalResponse | null { try { const raw = localStorage.getItem(scanCacheKey(tradingRegion)); if (!raw) return null; const cached = JSON.parse(raw); - if (cached.ts && Date.now() - cached.ts < SCAN_CACHE_TTL_MS && cached.data?.rows) { + const age = Date.now() - Number(cached.ts || 0); + const maxAge = options?.allowStale ? MAX_STALE_SCAN_CACHE_MS : SCAN_CACHE_TTL_MS; + if (cached.ts && age >= 0 && age < maxAge && cached.data?.rows) { return cached.data; } } catch { /* ignore */ } @@ -99,10 +105,17 @@ export function useScanTerminalQuery({ const lastForcedScanRefreshAtRef = useRef(0); const patchVersion = useSsePatchVersion(); const [cachedRows, setCachedRows] = useState(() => { - if (typeof window !== "undefined") return readScanCache(tradingRegion || ""); + if (typeof window !== "undefined") { + return readScanCache(tradingRegion || "", { allowStale: true }); + } return null; }); + useEffect(() => { + if (typeof window === "undefined") return; + setCachedRows(readScanCache(tradingRegion || "", { allowStale: true })); + }, [tradingRegion]); + const fetchScanTerminal = useCallback( async ({ forceRefresh = false, diff --git a/frontend/components/landing/__tests__/landingPricingReferral.test.ts b/frontend/components/landing/__tests__/landingPricingReferral.test.ts index 686584ad..9bed59e1 100644 --- a/frontend/components/landing/__tests__/landingPricingReferral.test.ts +++ b/frontend/components/landing/__tests__/landingPricingReferral.test.ts @@ -81,6 +81,11 @@ export function runTests() { assert(appPageSource.includes('price: "79.90"'), "JSON-LD must expose quarterly Pro pricing"); assert(!appPageSource.includes('price: "10.00"'), "legacy JSON-LD pricing must be removed"); assert(!appPageSource.includes("PreloadTerminalData"), "landing route must not add a fourth client island"); + assert( + !appPageSource.includes("AI decision cards") && + !appPageSource.includes("AI 气象证据链"), + "landing metadata and JSON-LD must not advertise the removed AI decision-card positioning", + ); } function projectRoot() { diff --git a/frontend/components/ops/__tests__/opsConfig.test.ts b/frontend/components/ops/__tests__/opsConfig.test.ts new file mode 100644 index 00000000..93bd4f75 --- /dev/null +++ b/frontend/components/ops/__tests__/opsConfig.test.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const source = fs.readFileSync( + path.join(projectRoot, "components", "ops", "config", "ConfigPageClient.tsx"), + "utf8", + ); + + assert( + source.includes("data-testid=\"sensitive-session-input\"") && + source.includes("spellCheck={false}") && + source.includes("autoCapitalize=\"none\""), + "ops sensitive session input must be optimized for exact sessionId entry", + ); + assert( + source.includes("Docker .env") && + source.includes("不需要把 $$ 写成 $$$$") && + source.includes("真实的 $$"), + "ops config page must explain that UI-entered sessionIds keep literal $$ and do not need Docker escaping", + ); + assert( + source.includes("最近检查") && + source.includes("sensitiveCheckedAt") && + source.includes("setSensitiveCheckedAt"), + "ops config page must show when the post-rotation sensitive health check was performed", + ); +} diff --git a/frontend/components/ops/config/ConfigPageClient.tsx b/frontend/components/ops/config/ConfigPageClient.tsx index d13e9533..43e714fa 100644 --- a/frontend/components/ops/config/ConfigPageClient.tsx +++ b/frontend/components/ops/config/ConfigPageClient.tsx @@ -42,6 +42,7 @@ export function ConfigPageClient() { const [result, setResult] = useState(""); const [sensitiveResult, setSensitiveResult] = useState(""); const [sensitiveHealth, setSensitiveHealth] = useState(null); + const [sensitiveCheckedAt, setSensitiveCheckedAt] = useState(""); const load = async () => { try { @@ -90,6 +91,7 @@ export function ConfigPageClient() { setSensitiveSaving(true); setSensitiveResult(""); setSensitiveHealth(null); + setSensitiveCheckedAt(""); try { const res = await fetch("/api/ops/sensitive-config", { method: "PUT", @@ -106,6 +108,7 @@ export function ConfigPageClient() { } setSensitiveEditing((prev) => { const n = { ...prev }; delete n[key]; return n; }); setSensitiveHealth(data.health ?? null); + setSensitiveCheckedAt(new Date().toLocaleString("zh-CN", { hour12: false })); setSensitiveResult(`${key} 已轮换`); } else { setSensitiveResult(`轮换失败: ${await res.text().catch(() => "")}`); @@ -214,8 +217,11 @@ export function ConfigPageClient() {
setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))} @@ -232,6 +238,9 @@ export function ConfigPageClient() {
+

+ 在 ops 页面粘贴真实的 $$ sessionId 即可;这里不是 Docker .env,不需要把 $$ 写成 $$$$。 +

))} @@ -244,6 +253,7 @@ export function ConfigPageClient() { {sensitiveHealth && (
AMSC 健康检查:{sensitiveHealth.ok ? "通过" : "失败"} + {sensitiveCheckedAt ? ` · 最近检查 ${sensitiveCheckedAt}` : ""} {typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""} {sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""} {sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""} diff --git a/tests/test_deployment_runtime_config.py b/tests/test_deployment_runtime_config.py index beb646d8..f239e015 100644 --- a/tests/test_deployment_runtime_config.py +++ b/tests/test_deployment_runtime_config.py @@ -74,7 +74,8 @@ def test_deploy_script_retries_startup_smoke_checks(): assert "smoke_check()" in script assert 'smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5' in script - assert 'smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3' in script + assert 'warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1"' in script + assert 'smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3' not in script assert 'smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5' in script assert 'smoke_check "frontend" "https://www.polyweather.top/" 15 3 5' in script diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index b4a59b9c..ad0efd31 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -11,6 +11,7 @@ import web.routes as routes import web.services.ops_api as ops_api import web.scan_terminal_cache as scan_terminal_cache import web.scan_terminal_service as scan_terminal_service +import web.services.city_api as city_api import web.services.city_runtime as city_runtime from web.scan_terminal_cache import scan_terminal_cache_key from src.database.runtime_state import TruthRecordRepository @@ -132,6 +133,30 @@ def test_cities_endpoint_includes_new_wunderground_cities(): }.issubset(names) +def test_cities_endpoint_does_not_block_on_recent_deb_index(monkeypatch): + monkeypatch.setattr(city_api, "_RECENT_DEB_CACHE", None, raising=False) + monkeypatch.setattr(city_api, "_RECENT_DEB_CACHE_TS", 0.0, raising=False) + monkeypatch.setattr(city_api, "_RECENT_DEB_REFRESHING", False, raising=False) + monkeypatch.setattr(city_api, "_get_recent_deb_cache", lambda: None, raising=False) + monkeypatch.setattr(city_api, "_start_recent_deb_refresh", lambda: None, raising=False) + + def fail_recent_index(): + raise AssertionError("recent DEB stats must not run in the default city-list request") + + monkeypatch.setattr( + city_api.legacy_routes, + "_build_recent_deb_performance_index", + fail_recent_index, + ) + + response = client.get("/api/cities") + + assert response.status_code == 200 + denver = next(item for item in response.json()["cities"] if item["name"] == "denver") + assert denver["deb_recent_tier"] == "other" + assert denver["deb_recent_sample_count"] == 0 + + def test_payment_runtime_endpoint_returns_shape(): response = client.get('/api/payments/runtime') assert response.status_code == 200 diff --git a/web/services/city_api.py b/web/services/city_api.py index 1ea469a3..7a3e8c3b 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -2,6 +2,9 @@ from __future__ import annotations +import os +import threading +import time from typing import Any, Dict, Optional from fastapi import HTTPException, Request @@ -10,6 +13,15 @@ from loguru import logger import web.routes as legacy_routes +_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None +_RECENT_DEB_CACHE_TS = 0.0 +_RECENT_DEB_REFRESHING = False +_RECENT_DEB_LOCK = threading.Lock() +_RECENT_DEB_CACHE_TTL_SEC = max( + 60, + int(os.getenv("POLYWEATHER_CITIES_DEB_RECENT_CACHE_TTL_SEC", "300") or "300"), +) + async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Dict[str, Any]: return await run_in_threadpool( @@ -19,13 +31,69 @@ async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Di ) -def _build_cities_payload() -> Dict[str, Any]: +def _default_deb_recent() -> Dict[str, object]: + return { + "tier": "other", + "hit_rate": None, + "sample_count": 0, + "mae": None, + "last_date": None, + } + + +def _refresh_recent_deb_cache() -> Dict[str, Dict[str, object]]: + global _RECENT_DEB_CACHE, _RECENT_DEB_CACHE_TS, _RECENT_DEB_REFRESHING + + try: + index = legacy_routes._build_recent_deb_performance_index() + with _RECENT_DEB_LOCK: + _RECENT_DEB_CACHE = index + _RECENT_DEB_CACHE_TS = time.time() + return index + except Exception as exc: + logger.warning(f"Recent DEB performance cache refresh failed: {exc}") + with _RECENT_DEB_LOCK: + return _RECENT_DEB_CACHE or {} + finally: + with _RECENT_DEB_LOCK: + _RECENT_DEB_REFRESHING = False + + +def _get_recent_deb_cache() -> Optional[Dict[str, Dict[str, object]]]: + with _RECENT_DEB_LOCK: + if ( + _RECENT_DEB_CACHE is not None + and time.time() - _RECENT_DEB_CACHE_TS < _RECENT_DEB_CACHE_TTL_SEC + ): + return _RECENT_DEB_CACHE + return None + + +def _start_recent_deb_refresh() -> None: + global _RECENT_DEB_REFRESHING + + with _RECENT_DEB_LOCK: + if _RECENT_DEB_REFRESHING: + return + _RECENT_DEB_REFRESHING = True + + thread = threading.Thread( + target=_refresh_recent_deb_cache, + name="cities-recent-deb-refresh", + daemon=True, + ) + thread.start() + + +def _build_cities_payload( + deb_recent_index: Optional[Dict[str, Dict[str, object]]] = None, +) -> Dict[str, Any]: out = [] - deb_recent_index = legacy_routes._build_recent_deb_performance_index() + deb_recent_index = deb_recent_index or {} for name, info in legacy_routes.CITIES.items(): risk = legacy_routes.CITY_RISK_PROFILES.get(name, {}) city_meta = legacy_routes.CITY_REGISTRY.get(name, {}) or {} - deb_recent = deb_recent_index.get(name, {}) + deb_recent = deb_recent_index.get(name, _default_deb_recent()) settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar" provider = legacy_routes.get_country_network_provider(name) out.append( @@ -60,9 +128,19 @@ def _build_cities_payload() -> Dict[str, Any]: return {"cities": out} -async def list_cities_payload(_request: Request) -> Dict[str, Any]: +async def list_cities_payload(request: Request) -> Dict[str, Any]: try: - return await run_in_threadpool(_build_cities_payload) + refresh_recent = str( + request.query_params.get("refresh_deb_recent") or "", + ).strip().lower() in {"1", "true", "yes"} + if refresh_recent: + deb_recent_index = await run_in_threadpool(_refresh_recent_deb_cache) + else: + deb_recent_index = _get_recent_deb_cache() + if deb_recent_index is None: + _start_recent_deb_refresh() + deb_recent_index = {} + return await run_in_threadpool(_build_cities_payload, deb_recent_index) except Exception as exc: logger.error(f"Error in list_cities: {exc}") raise HTTPException(status_code=500, detail=str(exc)) from exc