Optimize terminal startup and deployment smoke checks

This commit is contained in:
2569718930@qq.com
2026-05-31 01:56:08 +08:00
parent bf0fbe1b1d
commit ca72072da0
21 changed files with 393 additions and 41 deletions
+1 -1
View File
@@ -146,11 +146,11 @@ fi
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3 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 "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 warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
FAILED=0 FAILED=0
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1 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 cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1 smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1
+34 -3
View File
@@ -4,7 +4,6 @@ import {
buildBackendRequestHeaders, buildBackendRequestHeaders,
} from "@/lib/backend-auth"; } from "@/lib/backend-auth";
import { import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import { import {
@@ -136,6 +135,27 @@ function subscriptionRequiredAuthProfileResponse({
return applyAuthResponseCookies(inactive, response); 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({ function snapshotAuthProfileResponse({
email, email,
reason, reason,
@@ -377,8 +397,19 @@ export async function GET(req: NextRequest) {
userId: identity.userId, userId: identity.userId,
}); });
} }
return buildProxyExceptionResponse(error, { const snapshotPayload = entitlementSnapshotToAuthPayload(
publicMessage: "Failed to fetch auth profile", 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,
}); });
} }
} }
+2 -2
View File
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
template: "%s | PolyWeather", template: "%s | PolyWeather",
}, },
description: 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", manifest: "/site.webmanifest",
metadataBase: new URL("https://polyweather.top"), metadataBase: new URL("https://polyweather.top"),
alternates: { alternates: {
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
siteName: "PolyWeather", siteName: "PolyWeather",
title: "PolyWeather | Institutional Weather Signal Intelligence", title: "PolyWeather | Institutional Weather Signal Intelligence",
description: 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", url: "https://polyweather.top",
images: [ images: [
{ {
+2 -2
View File
@@ -5,7 +5,7 @@ import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLand
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather | Institutional Weather Signal Intelligence", title: "PolyWeather | Institutional Weather Signal Intelligence",
description: 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: { other: {
preconnect: "https://api.polyweather.top", preconnect: "https://api.polyweather.top",
}, },
@@ -37,7 +37,7 @@ export default async function HomePage({
"@type": "WebApplication", "@type": "WebApplication",
name: "PolyWeather", name: "PolyWeather",
description: 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", url: "https://polyweather.top",
applicationCategory: "BusinessApplication", applicationCategory: "BusinessApplication",
operatingSystem: "Web", operatingSystem: "Web",
+12 -1
View File
@@ -1,5 +1,16 @@
import type { Metadata } from "next"; 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: () => <DashboardShellSkeleton />,
},
);
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PolyWeather Terminal | Paid Product", title: "PolyWeather Terminal | Paid Product",
@@ -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", "/api/auth/me must verify bearer tokens directly and return a degraded authenticated profile when the backend auth profile is transiently unavailable",
); );
assert( assert(
authMeRouteSource.indexOf("const bearerIdentity = await getVerifiedBearerIdentity(req)") < authMeRouteSource.includes("const identity = await getBearerIdentityOnce();") &&
authMeRouteSource.indexOf("return buildProxyExceptionResponse(error"), !authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
"/api/auth/me must try bearer identity fallback before returning a proxy exception", "/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 [ for (const route of [
"app/api/ops/analytics/funnel/route.ts", "app/api/ops/analytics/funnel/route.ts",
"app/api/ops/config/route.ts", "app/api/ops/config/route.ts",
@@ -372,4 +372,11 @@ export function runTests() {
.length >= 3, .length >= 3,
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON", "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",
);
} }
+32 -12
View File
@@ -183,6 +183,35 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
trackAppEvent("payment_success", payload); trackAppEvent("payment_success", payload);
}, []); }, []);
const verifyPaymentAuthReady = useCallback(async () => {
const accessToken = await getValidAccessToken();
const authHeaders: Record<string, string> = {
"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 availableChainList: PaymentChainOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : []; const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
const clean = configured const clean = configured
@@ -401,6 +430,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentBusy(true); setPaymentBusy(true);
let approvedInThisRun = false; let approvedInThisRun = false;
try { try {
const authReady = await verifyPaymentAuthReady();
const authHeaders = authReady.authHeaders;
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey); const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
const eth = providerSelection.provider; const eth = providerSelection.provider;
const activeAccounts = await requestWalletWithTimeout<string[]>( const activeAccounts = await requestWalletWithTimeout<string[]>(
@@ -418,18 +449,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setSelectedWallet(payingWallet); setSelectedWallet(payingWallet);
setProviderMode(providerSelection.mode); setProviderMode(providerSelection.mode);
let accessToken: string;
try { accessToken = await getValidAccessToken(); }
catch (tokenErr) {
setPaymentError(normalizePaymentError(tokenErr).message);
setPaymentBusy(false);
return;
}
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true); const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady); if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137); const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137);
@@ -480,6 +499,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
source: "account_center", source: "account_center",
frontend_host: currentPaymentHost || null, frontend_host: currentPaymentHost || null,
account_email: backend?.email || null, account_email: backend?.email || null,
auth_confirmed_at: authReady.auth_confirmed_at,
}, },
}), }),
}); });
+2 -2
View File
@@ -108,8 +108,8 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions." ? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
: "继续操作即代表您同意隐私政策与服务条款。", : "继续操作即代表您同意隐私政策与服务条款。",
desc: isEn desc: isEn
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses." ? "Access robust METAR observations, advanced DEB forecast blends, and structured decision context for weather risk analysis."
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。", : "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。",
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖", trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
} as const; } as const;
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting; const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
@@ -1,6 +1,7 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { import {
Activity, Activity,
@@ -44,7 +45,6 @@ import { ScanTerminalLoadingScreen } from "@/components/dashboard/scan-terminal/
import { scanRootClass } from "@/components/dashboard/scan-root-styles"; import { scanRootClass } from "@/components/dashboard/scan-root-styles";
import { useRelativeTime } from "@/hooks/useRelativeTime"; import { useRelativeTime } from "@/hooks/useRelativeTime";
import { Panel } from "@/components/dashboard/scan-terminal/Panel"; 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 { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard";
import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable"; import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable";
@@ -62,6 +62,20 @@ import {
} from "@/components/dashboard/scan-terminal/city-fallback-rows"; } from "@/components/dashboard/scan-terminal/city-fallback-rows";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
const TrainingDashboard = dynamic(
() =>
import("@/components/dashboard/scan-terminal/TrainingDashboard").then(
(mod) => mod.TrainingDashboard,
),
{
loading: () => (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs font-semibold text-slate-400">
Loading analytics...
</div>
),
},
);
function createEmptyAccess(loading = true): ProAccessState { function createEmptyAccess(loading = true): ProAccessState {
return { return {
loading, loading,
@@ -1037,6 +1051,9 @@ function ScanTerminalScreen() {
hydrated && (proAccess.authenticated || canUseLocalFullAccess); hydrated && (proAccess.authenticated || canUseLocalFullAccess);
const isPro = const isPro =
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess); hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
const accessDecisionPending =
!hydrated || (proAccess.loading && !canUseLocalFullAccess);
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
const userLocalTime = useUserLocalClock(); const userLocalTime = useUserLocalClock();
const { themeMode } = useScanTerminalTheme(); const { themeMode } = useScanTerminalTheme();
const [selectedRegionKey, setSelectedRegionKey] = useState<string>("all"); const [selectedRegionKey, setSelectedRegionKey] = useState<string>("all");
@@ -1272,7 +1289,7 @@ function ScanTerminalScreen() {
}, []); }, []);
const generatedText = useRelativeTime(terminalData?.generated_at ?? null); const generatedText = useRelativeTime(terminalData?.generated_at ?? null);
if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) { if (accessDecisionPending) {
return ( return (
<ScanTerminalLoadingScreen <ScanTerminalLoadingScreen
isEn={isEn} isEn={isEn}
@@ -1283,7 +1300,7 @@ function ScanTerminalScreen() {
); );
} }
if (!isAuthenticated || !isPro) { if (shouldShowPaywall) {
return ( return (
<ProductAccessRequired <ProductAccessRequired
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { REGIONS, getCityRegion } from "./continent-grouping"; import { REGIONS, getCityRegion } from "./continent-grouping";
@@ -126,6 +126,7 @@ export function CitySelectorDropdown({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const [activeTab, setActiveTab] = useState<string>("all"); const [activeTab, setActiveTab] = useState<string>("all");
const [viewportNudgeY, setViewportNudgeY] = useState(0); const [viewportNudgeY, setViewportNudgeY] = useState(0);
@@ -170,7 +171,7 @@ export function CitySelectorDropdown({
// Filter rows // Filter rows
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = searchQuery.toLowerCase().trim(); const q = deferredSearchQuery.toLowerCase().trim();
return rows.filter((row) => { return rows.filter((row) => {
// 1. Region filter // 1. Region filter
if (activeTab !== "all") { if (activeTab !== "all") {
@@ -195,7 +196,7 @@ export function CitySelectorDropdown({
.map((s) => s!.toLowerCase()); .map((s) => s!.toLowerCase());
return haystack.some((s) => s.includes(q)); return haystack.some((s) => s.includes(q));
}); });
}, [rows, searchQuery, activeTab]); }, [rows, deferredSearchQuery, activeTab]);
useEffect(() => { useEffect(() => {
let frame = 0; let frame = 0;
@@ -1,12 +1,13 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches"; import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { Panel } from "@/components/dashboard/scan-terminal/Panel";
import { ModelCurvesSummary } from "@/components/dashboard/scan-terminal/ModelCurvesSummary"; 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 { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails";
import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars"; import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { rowName } from "@/components/dashboard/scan-terminal/utils"; 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 PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
const TemperatureChartCanvas = dynamic(
() =>
import("@/components/dashboard/scan-terminal/TemperatureChartCanvas").then(
(mod) => mod.TemperatureChartCanvas,
),
{
ssr: false,
loading: () => <TemperatureChartCanvasFallback />,
},
);
function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) { function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) {
if (state === "watch") return isEn ? "Watch" : "关注"; if (state === "watch") return isEn ? "Watch" : "关注";
if (state === "near_peak") return isEn ? "Near peak" : "接近峰值"; if (state === "near_peak") return isEn ? "Near peak" : "接近峰值";
@@ -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 (
<div
className="relative flex-1 overflow-hidden rounded-sm border border-slate-100 bg-white"
style={{ minHeight: minChartHeight }}
>
<div className="absolute inset-x-3 bottom-7 top-4 rounded-sm border border-slate-100">
{Array.from({ length: horizontalLines }).map((_, index) => (
<span
key={`h-${index}`}
className="absolute left-0 right-0 border-t border-dashed border-sky-100"
style={{ top: `${(index / Math.max(1, horizontalLines - 1)) * 100}%` }}
/>
))}
{Array.from({ length: verticalLines }).map((_, index) => (
<span
key={`v-${index}`}
className="absolute bottom-0 top-0 border-l border-dashed border-sky-100"
style={{ left: `${(index / Math.max(1, verticalLines - 1)) * 100}%` }}
/>
))}
</div>
<div className="absolute inset-0 grid place-items-center">
<div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-500 shadow-sm">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-500" />
</div>
</div>
</div>
);
}
@@ -20,6 +20,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8", "utf8",
); );
const terminalPageSource = fs.readFileSync(
path.join(projectRoot, "app", "terminal", "page.tsx"),
"utf8",
);
const chartCanvasSource = fs.readFileSync( const chartCanvasSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8", "utf8",
@@ -28,6 +32,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"),
"utf8", "utf8",
); );
const scanQuerySource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"),
"utf8",
);
assert( assert(
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"), dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
@@ -75,6 +83,26 @@ export function runTests() {
citySelectorSource.includes("getBoundingClientRect()"), citySelectorSource.includes("getBoundingClientRect()"),
"city selector dropdown must nudge itself inside the viewport when opened from top-row chart cards", "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( assert(
chartSource.includes("setLiveTemp(null);") && chartSource.includes("setLiveTemp(null);") &&
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"), chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
@@ -109,6 +137,25 @@ export function runTests() {
dashboardSource.includes("[rows, deferredSearchQuery]"), dashboardSource.includes("[rows, deferredSearchQuery]"),
"terminal search must defer expensive row filtering so typing stays responsive", "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( assert(
dashboardSource.includes('trackAppEvent("enter_terminal"') && dashboardSource.includes('trackAppEvent("enter_terminal"') &&
dashboardSource.includes('entry: "terminal"'), dashboardSource.includes('entry: "terminal"'),
@@ -17,17 +17,23 @@ import type { ScanTerminalResponse } from "@/lib/dashboard-types";
const SCAN_CACHE_PREFIX = "polyweather_scan_v2"; const SCAN_CACHE_PREFIX = "polyweather_scan_v2";
const SCAN_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.scanRows; 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 { function scanCacheKey(tradingRegion: string): string {
return `${SCAN_CACHE_PREFIX}:${tradingRegion || "all"}`; return `${SCAN_CACHE_PREFIX}:${tradingRegion || "all"}`;
} }
function readScanCache(tradingRegion: string): ScanTerminalResponse | null { function readScanCache(
tradingRegion: string,
options?: { allowStale?: boolean },
): ScanTerminalResponse | null {
try { try {
const raw = localStorage.getItem(scanCacheKey(tradingRegion)); const raw = localStorage.getItem(scanCacheKey(tradingRegion));
if (!raw) return null; if (!raw) return null;
const cached = JSON.parse(raw); 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; return cached.data;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
@@ -99,10 +105,17 @@ export function useScanTerminalQuery({
const lastForcedScanRefreshAtRef = useRef(0); const lastForcedScanRefreshAtRef = useRef(0);
const patchVersion = useSsePatchVersion(); const patchVersion = useSsePatchVersion();
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => { const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
if (typeof window !== "undefined") return readScanCache(tradingRegion || ""); if (typeof window !== "undefined") {
return readScanCache(tradingRegion || "", { allowStale: true });
}
return null; return null;
}); });
useEffect(() => {
if (typeof window === "undefined") return;
setCachedRows(readScanCache(tradingRegion || "", { allowStale: true }));
}, [tradingRegion]);
const fetchScanTerminal = useCallback( const fetchScanTerminal = useCallback(
async ({ async ({
forceRefresh = false, forceRefresh = false,
@@ -81,6 +81,11 @@ export function runTests() {
assert(appPageSource.includes('price: "79.90"'), "JSON-LD must expose quarterly Pro pricing"); 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('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("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() { function projectRoot() {
@@ -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",
);
}
@@ -42,6 +42,7 @@ export function ConfigPageClient() {
const [result, setResult] = useState(""); const [result, setResult] = useState("");
const [sensitiveResult, setSensitiveResult] = useState(""); const [sensitiveResult, setSensitiveResult] = useState("");
const [sensitiveHealth, setSensitiveHealth] = useState<SensitiveHealth | null>(null); const [sensitiveHealth, setSensitiveHealth] = useState<SensitiveHealth | null>(null);
const [sensitiveCheckedAt, setSensitiveCheckedAt] = useState("");
const load = async () => { const load = async () => {
try { try {
@@ -90,6 +91,7 @@ export function ConfigPageClient() {
setSensitiveSaving(true); setSensitiveSaving(true);
setSensitiveResult(""); setSensitiveResult("");
setSensitiveHealth(null); setSensitiveHealth(null);
setSensitiveCheckedAt("");
try { try {
const res = await fetch("/api/ops/sensitive-config", { const res = await fetch("/api/ops/sensitive-config", {
method: "PUT", method: "PUT",
@@ -106,6 +108,7 @@ export function ConfigPageClient() {
} }
setSensitiveEditing((prev) => { const n = { ...prev }; delete n[key]; return n; }); setSensitiveEditing((prev) => { const n = { ...prev }; delete n[key]; return n; });
setSensitiveHealth(data.health ?? null); setSensitiveHealth(data.health ?? null);
setSensitiveCheckedAt(new Date().toLocaleString("zh-CN", { hour12: false }));
setSensitiveResult(`${key} 已轮换`); setSensitiveResult(`${key} 已轮换`);
} else { } else {
setSensitiveResult(`轮换失败: ${await res.text().catch(() => "")}`); setSensitiveResult(`轮换失败: ${await res.text().catch(() => "")}`);
@@ -214,8 +217,11 @@ export function ConfigPageClient() {
</div> </div>
<div className="flex w-full flex-col gap-2 sm:flex-row xl:w-[520px]"> <div className="flex w-full flex-col gap-2 sm:flex-row xl:w-[520px]">
<input <input
data-testid="sensitive-session-input"
type="password" type="password"
autoComplete="off" autoComplete="off"
autoCapitalize="none"
spellCheck={false}
placeholder="输入新的 sessionId,不会回显" placeholder="输入新的 sessionId,不会回显"
value={sensitiveEditing[cfg.key] ?? ""} value={sensitiveEditing[cfg.key] ?? ""}
onChange={(e) => setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))} onChange={(e) => setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
@@ -232,6 +238,9 @@ export function ConfigPageClient() {
</Button> </Button>
</div> </div>
</div> </div>
<p className="mt-3 text-xs leading-5 text-slate-500">
ops $$ sessionId Docker .env $$ $$$$
</p>
</div> </div>
))} ))}
</div> </div>
@@ -244,6 +253,7 @@ export function ConfigPageClient() {
{sensitiveHealth && ( {sensitiveHealth && (
<div className={`mt-3 rounded-lg border px-3 py-2 text-xs ${sensitiveHealth.ok ? "border-emerald-400/20 bg-emerald-400/10 text-emerald-200" : "border-amber-400/20 bg-amber-400/10 text-amber-200"}`}> <div className={`mt-3 rounded-lg border px-3 py-2 text-xs ${sensitiveHealth.ok ? "border-emerald-400/20 bg-emerald-400/10 text-emerald-200" : "border-amber-400/20 bg-amber-400/10 text-amber-200"}`}>
AMSC {sensitiveHealth.ok ? "通过" : "失败"} AMSC {sensitiveHealth.ok ? "通过" : "失败"}
{sensitiveCheckedAt ? ` · 最近检查 ${sensitiveCheckedAt}` : ""}
{typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""} {typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""}
{sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""} {sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""}
{sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""} {sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""}
+2 -1
View File
@@ -74,7 +74,8 @@ def test_deploy_script_retries_startup_smoke_checks():
assert "smoke_check()" in script assert "smoke_check()" in script
assert 'smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5' 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 cities" "https://polyweather.top/api/cities" 20 5 5' in script
assert 'smoke_check "frontend" "https://www.polyweather.top/" 15 3 5' in script assert 'smoke_check "frontend" "https://www.polyweather.top/" 15 3 5' in script
+25
View File
@@ -11,6 +11,7 @@ import web.routes as routes
import web.services.ops_api as ops_api import web.services.ops_api as ops_api
import web.scan_terminal_cache as scan_terminal_cache import web.scan_terminal_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service 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 import web.services.city_runtime as city_runtime
from web.scan_terminal_cache import scan_terminal_cache_key from web.scan_terminal_cache import scan_terminal_cache_key
from src.database.runtime_state import TruthRecordRepository from src.database.runtime_state import TruthRecordRepository
@@ -132,6 +133,30 @@ def test_cities_endpoint_includes_new_wunderground_cities():
}.issubset(names) }.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(): def test_payment_runtime_endpoint_returns_shape():
response = client.get('/api/payments/runtime') response = client.get('/api/payments/runtime')
assert response.status_code == 200 assert response.status_code == 200
+83 -5
View File
@@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
import os
import threading
import time
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
@@ -10,6 +13,15 @@ from loguru import logger
import web.routes as legacy_routes 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]: async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
return await run_in_threadpool( 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 = [] 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(): for name, info in legacy_routes.CITIES.items():
risk = legacy_routes.CITY_RISK_PROFILES.get(name, {}) risk = legacy_routes.CITY_RISK_PROFILES.get(name, {})
city_meta = legacy_routes.CITY_REGISTRY.get(name, {}) or {} 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" settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar"
provider = legacy_routes.get_country_network_provider(name) provider = legacy_routes.get_country_network_provider(name)
out.append( out.append(
@@ -60,9 +128,19 @@ def _build_cities_payload() -> Dict[str, Any]:
return {"cities": out} return {"cities": out}
async def list_cities_payload(_request: Request) -> Dict[str, Any]: async def list_cities_payload(request: Request) -> Dict[str, Any]:
try: 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: except Exception as exc:
logger.error(f"Error in list_cities: {exc}") logger.error(f"Error in list_cities: {exc}")
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc