diff --git a/frontend/app/api/cities/detail-batch/route.ts b/frontend/app/api/cities/detail-batch/route.ts new file mode 100644 index 00000000..40436706 --- /dev/null +++ b/frontend/app/api/cities/detail-batch/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; +import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const DETAIL_BATCH_PROXY_TIMEOUT_MS = Number( + process.env.POLYWEATHER_CITY_DETAIL_BATCH_PROXY_TIMEOUT_MS || "12000", +); + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; + const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); + const searchParams = new URLSearchParams({ + cities: req.nextUrl.searchParams.get("cities") || "", + force_refresh: forceRefresh, + limit: req.nextUrl.searchParams.get("limit") || "12", + }); + for (const key of ["market_slug", "target_date", "resolution"]) { + const value = req.nextUrl.searchParams.get(key); + if (value) searchParams.set(key, value); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DETAIL_BATCH_PROXY_TIMEOUT_MS); + + try { + return await proxyBackendJsonGet(req, { + cacheControl: cachePolicy.responseCacheControl, + fetchCache: + cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, + publicMessage: "Failed to fetch city detail batch", + revalidateSeconds: cachePolicy.revalidateSeconds, + signal: controller.signal, + timeoutPublicMessage: "City detail batch request timed out", + url: `${API_BASE}/api/cities/detail-batch?${searchParams.toString()}`, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 321242d8..827cff53 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -61,6 +61,7 @@ import { mergeScanRowsWithCityFallbackRows, } from "@/components/dashboard/scan-terminal/city-fallback-rows"; import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; +import { STATIC_CITY_LIST } from "@/lib/static-cities"; const TrainingDashboard = dynamic( () => @@ -1218,7 +1219,9 @@ function ScanTerminalScreen() { refreshScanTerminalManually(); }, [refreshScanTerminalManually]); - const [cityFallbackRows, setCityFallbackRows] = useState([]); + const [cityFallbackRows, setCityFallbackRows] = useState(() => + cityListItemsToScanRows(STATIC_CITY_LIST), + ); const rows = useMemo( () => { const scanRows = terminalData?.rows || []; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts index bf4dc988..bd49ff1e 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts @@ -121,9 +121,11 @@ export function runTests() { assert( dashboardSource.includes("cityListItemsToScanRows") && + dashboardSource.includes("STATIC_CITY_LIST") && + dashboardSource.includes("useState(() =>") && dashboardSource.includes("/api/cities") && dashboardSource.includes("cityFallbackRows"), - "terminal dashboard should use /api/cities fallback rows when scan terminal rows are not ready", + "terminal dashboard should seed fallback rows from the static city snapshot before refreshing /api/cities", ); assert(fs.existsSync(staticCitiesPath), "/api/cities route should have a static city snapshot fallback"); assert( diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 6651b493..f635bbf0 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -85,6 +85,17 @@ export async function runTests() { chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), "terminal charts should render from row data immediately and dedupe concurrent city detail requests", ); + assert( + chartLogicSource.includes("/api/cities/detail-batch") && + chartLogicSource.includes("flushCityDetailBatch") && + chartLogicSource.includes("primeCityDetailCache"), + "visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache", + ); + assert( + chartLogicSource.includes("options.ignoreCache\n ? runQueuedHourlyDetailRequest") && + chartLogicSource.includes(": queueCityDetailBatch(city, resParam)"), + "normal first-paint city detail requests should enter the batch queue before single-request concurrency limiting", + ); assert( chartSource.includes("IntersectionObserver") && chartSource.includes("shouldFetchCityDetailForChart") && diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index a3e8e8c2..0dc547d1 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -965,6 +965,26 @@ type HourlyForecastFetchOptions = { resolution?: string; }; +type CityDetailBatchPayload = { + details?: Record; + errors?: Record; +}; + +type CityDetailBatchWaiter = { + resolve: (value: HourlyForecast) => void; + reject: (reason?: unknown) => void; +}; + +type CityDetailBatchQueue = { + cities: Set; + waiters: Map; + timer: ReturnType | null; +}; + +const CITY_DETAIL_BATCH_WINDOW_MS = 25; +const CITY_DETAIL_BATCH_MAX_CITIES = 12; +const _cityDetailBatchQueues = new Map(); + function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast { const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; if (!json || !hourlySource) return null; @@ -993,6 +1013,133 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec }; } +function primeCityDetailCache( + city: string, + resolution: string, + detail: CityDetail | null | undefined, +): HourlyForecast { + const data = parseHourlyForecastFromCityDetail(detail || null); + if (!data) return null; + const cacheKey = `${city}:${resolution}`; + _hourlyCache.set(cacheKey, { ts: Date.now(), data }); + writeSessionCache(cacheKey, data); + return data; +} + +async function fetchSingleHourlyForecastForCity( + city: string, + resolution: string, +): Promise { + const res = await fetchCityDetailWithTimeout(city, resolution); + if (!res || !res.ok) return null; + const json = await res.json() as CityDetail; + return primeCityDetailCache(city, resolution, json); +} + +function queueCityDetailBatch(city: string, resolution: string): Promise { + return new Promise((resolve, reject) => { + const queue = _cityDetailBatchQueues.get(resolution) || { + cities: new Set(), + waiters: new Map(), + timer: null, + }; + _cityDetailBatchQueues.set(resolution, queue); + + const cityWaiters = queue.waiters.get(city) || []; + cityWaiters.push({ resolve, reject }); + queue.waiters.set(city, cityWaiters); + queue.cities.add(city); + + if (queue.timer === null) { + queue.timer = setTimeout(() => flushCityDetailBatch(resolution), CITY_DETAIL_BATCH_WINDOW_MS); + } + if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) { + flushCityDetailBatch(resolution); + } + }); +} + +function resolveBatchWaiters( + waiters: CityDetailBatchWaiter[] | undefined, + value: HourlyForecast, +) { + (waiters || []).forEach((waiter) => waiter.resolve(value)); +} + +function rejectBatchWaiters( + waiters: CityDetailBatchWaiter[] | undefined, + reason: unknown, +) { + (waiters || []).forEach((waiter) => waiter.reject(reason)); +} + +async function flushCityDetailBatch(resolution: string) { + const queue = _cityDetailBatchQueues.get(resolution); + if (!queue) return; + _cityDetailBatchQueues.delete(resolution); + if (queue.timer !== null) { + clearTimeout(queue.timer); + queue.timer = null; + } + + const cities = Array.from(queue.cities).sort(); + if (!cities.length) return; + + try { + const payload = await fetchCityDetailBatchWithTimeout(cities, resolution); + const details = payload?.details || {}; + await Promise.all( + cities.map(async (city) => { + const waiters = queue.waiters.get(city); + const detail = details[city]; + const data = primeCityDetailCache(city, resolution, detail); + if (data) { + resolveBatchWaiters(waiters, data); + return; + } + try { + resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution)); + } catch (error) { + rejectBatchWaiters(waiters, error); + } + }), + ); + } catch (error) { + await Promise.all( + cities.map(async (city) => { + const waiters = queue.waiters.get(city); + try { + resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution)); + } catch (fallbackError) { + rejectBatchWaiters(waiters, fallbackError || error); + } + }), + ); + } +} + +function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) { + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS); + const params = new URLSearchParams({ + cities: cities.join(","), + depth: "full", + force_refresh: "false", + limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)), + resolution, + }); + return fetch(`/api/cities/detail-batch?${params.toString()}`, { + headers: { Accept: "application/json" }, + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) return null; + return res.json() as Promise; + }) + .catch(() => null) + .finally(() => globalThis.clearTimeout(timeoutId)); +} + async function fetchHourlyForecastForCity( city: string, options: HourlyForecastFetchOptions = {}, @@ -1011,19 +1158,10 @@ async function fetchHourlyForecastForCity( const pending = _hourlyRequestCache.get(requestKey); if (pending) return pending; - const request = runQueuedHourlyDetailRequest(() => - fetchCityDetailWithTimeout(city, resParam) - .then(async (res) => { - if (!res || !res.ok) return null; - return res.json() as Promise; - }) - .then((json) => { - const data = parseHourlyForecastFromCityDetail(json); - if (!data) return null; - _hourlyCache.set(cacheKey, { ts: Date.now(), data }); - writeSessionCache(cacheKey, data); - return data; - }), + const request = ( + options.ignoreCache + ? runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resParam)) + : queueCityDetailBatch(city, resParam) ) .finally(() => { _hourlyRequestCache.delete(requestKey); diff --git a/frontend/components/ops/__tests__/opsSourceHealth.test.ts b/frontend/components/ops/__tests__/opsSourceHealth.test.ts index aeb79564..a2f772bf 100644 --- a/frontend/components/ops/__tests__/opsSourceHealth.test.ts +++ b/frontend/components/ops/__tests__/opsSourceHealth.test.ts @@ -27,8 +27,12 @@ export function runTests() { systemPage.includes("sourceHealth") && systemPage.includes("MGM、KNMI、IMS") && systemPage.includes("断线") && - systemPage.includes("延迟"), - "ops system page must show source latency/disconnect status for operational city sources", + systemPage.includes("延迟") && + systemPage.includes("sourceReasonLabel") && + systemPage.includes("观测时间缺失") && + systemPage.includes("formatOpsValue") && + systemPage.includes("强制刷新"), + "ops system page must show readable source reasons, object values, and cache force-refresh metrics", ); assert( nextRoute.includes("requireOpsProxyAuth") && diff --git a/frontend/components/ops/payments/PaymentsPageClient.tsx b/frontend/components/ops/payments/PaymentsPageClient.tsx index 912d0d1c..8e82f516 100644 --- a/frontend/components/ops/payments/PaymentsPageClient.tsx +++ b/frontend/components/ops/payments/PaymentsPageClient.tsx @@ -50,6 +50,25 @@ function compactDate(value?: string) { return value.slice(0, 19).replace("T", " "); } +function paymentReasonLabel(reason?: string) { + const key = String(reason || "").trim().toLowerCase(); + if (key === "receiver_mismatch") return "收款地址不匹配"; + if (key === "amount_mismatch") return "金额不匹配"; + if (key === "chain_mismatch") return "支付网络不匹配"; + if (key === "tx_not_found") return "链上交易未找到"; + if (key === "tx_reverted") return "链上交易失败"; + if (key === "expired") return "订单已过期"; + if (key === "unknown") return "未知原因"; + return key || "未知原因"; +} + +function compactMono(value?: string, head = 10, tail = 6) { + const text = String(value || "").trim(); + if (!text) return "—"; + if (text.length <= head + tail + 3) return text; + return `${text.slice(0, head)}...${text.slice(-tail)}`; +} + function RiskStat({ label, value, @@ -148,7 +167,7 @@ export function PaymentsPageClient() {
- + @@ -287,7 +306,8 @@ export function PaymentsPageClient() { ID - 原因 + 原因 / 详情 + 用户 / Intent 时间 操作 @@ -296,7 +316,16 @@ export function PaymentsPageClient() { {incidents.map((inc) => ( {inc.id} - {inc.reason ?? "—"} + +
{paymentReasonLabel(inc.reason)}
+
+ {inc.detail || inc.reason || "—"} +
+ + +
{compactMono(inc.user_id)}
+
{compactMono(inc.intent_id, 12, 6)}
+ {inc.created_at?.slice(0, 19) ?? "—"}