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
@@ -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",
@@ -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",
);
}
+32 -12
View File
@@ -183,6 +183,35 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
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 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<string[]>(
@@ -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<string, string> = {
"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,
},
}),
});
+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."
: "继续操作即代表您同意隐私政策与服务条款。",
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;
@@ -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: () => (
<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 {
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<string>("all");
@@ -1272,7 +1289,7 @@ function ScanTerminalScreen() {
}, []);
const generatedText = useRelativeTime(terminalData?.generated_at ?? null);
if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) {
if (accessDecisionPending) {
return (
<ScanTerminalLoadingScreen
isEn={isEn}
@@ -1283,7 +1300,7 @@ function ScanTerminalScreen() {
);
}
if (!isAuthenticated || !isPro) {
if (shouldShowPaywall) {
return (
<ProductAccessRequired
isAuthenticated={isAuthenticated}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { REGIONS, getCityRegion } from "./continent-grouping";
@@ -126,6 +126,7 @@ export function CitySelectorDropdown({
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const [activeTab, setActiveTab] = useState<string>("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;
@@ -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: () => <TemperatureChartCanvasFallback />,
},
);
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" : "接近峰值";
@@ -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"),
"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"'),
@@ -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<ScanTerminalResponse | null>(() => {
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,
@@ -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() {
@@ -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 [sensitiveResult, setSensitiveResult] = useState("");
const [sensitiveHealth, setSensitiveHealth] = useState<SensitiveHealth | null>(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() {
</div>
<div className="flex w-full flex-col gap-2 sm:flex-row xl:w-[520px]">
<input
data-testid="sensitive-session-input"
type="password"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
placeholder="输入新的 sessionId,不会回显"
value={sensitiveEditing[cfg.key] ?? ""}
onChange={(e) => setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
@@ -232,6 +238,9 @@ export function ConfigPageClient() {
</Button>
</div>
</div>
<p className="mt-3 text-xs leading-5 text-slate-500">
ops $$ sessionId Docker .env $$ $$$$
</p>
</div>
))}
</div>
@@ -244,6 +253,7 @@ export function ConfigPageClient() {
{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"}`}>
AMSC {sensitiveHealth.ok ? "通过" : "失败"}
{sensitiveCheckedAt ? ` · 最近检查 ${sensitiveCheckedAt}` : ""}
{typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""}
{sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""}
{sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""}