perf(energy): SWR 缓存 + 自调度刷新,氢能总览 6s → 13ms
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

接口侧:
- cache.ts 改为 stale-while-revalidate:每个 key 自调度,TTL 到期前 5s 后台刷新,用户永远命中热缓存
- 闲置 10 分钟后停止调度,避免空跑
- loader 失败保留旧值 + 10s 后退避重试
- 所有 4 个端点支持 ?force=1 强制绕过缓存

前端 HydrogenOverview:
- 顶部加 RefreshCw 按钮(强刷绕过缓存),带旋转动画
- 显示"更新于 X 秒前"相对时间
- 刷新中:顶部 0.5px 流光进度条,不替换内容、不闪烁
- 60s 静默自动刷新(命中后端热缓存)

实测:cold 6.1s → 命中 13ms(470× 提速)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-30 17:43:24 +08:00
parent 6ad4b5e2a4
commit f06b0d21eb
4 changed files with 211 additions and 47 deletions

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts'; } from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp } from 'lucide-react'; import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
@@ -87,16 +88,37 @@ export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null); const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [year, setYear] = useState<number | null>(null); const [year, setYear] = useState<number | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
const refreshSeq = useRef(0);
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
const seq = ++refreshSeq.current;
setRefreshing(true);
try {
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
if (seq !== refreshSeq.current) return; // outdated
setData(d);
setError(null);
setLastRefreshAt(Date.now());
} catch (e) {
if (seq !== refreshSeq.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (seq === refreshSeq.current) setRefreshing(false);
}
}, []);
// 初始加载 + 年份切换:用 force=false 命中热缓存
useEffect(() => { void load(year, false); }, [year, load]);
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
useEffect(() => { useEffect(() => {
let cancelled = false; const t = setInterval(() => { void load(year, false); }, 60_000);
fetchHydrogenOverview(year ?? undefined) return () => clearInterval(t);
.then(d => { if (!cancelled) setData(d); }) }, [year, load]);
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [year]);
if (error) { if (error && !data) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>; return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
} }
if (!data) { if (!data) {
@@ -133,10 +155,11 @@ export default function HydrogenOverview() {
})); }));
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 relative">
{/* 顶部说明条 + 年份切换 */} {/* 顶部说明条 + 年份切换 + 刷新按钮 */}
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2"> <div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span> 2025-01-01 · </span> <span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5"> <div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => { {availableYears.map(y => {
const active = y === activeYear; const active = y === activeYear;
@@ -153,6 +176,16 @@ export default function HydrogenOverview() {
); );
})} })}
</div> </div>
<button
onClick={() => void load(year, true)}
disabled={refreshing}
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="手动刷新(绕过缓存)"
>
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
<span className="text-[11px] font-bold"></span>
</button>
</div>
</div> </div>
{/* KPI 5 卡 */} {/* KPI 5 卡 */}
@@ -489,10 +522,43 @@ export default function HydrogenOverview() {
)} )}
<RotatingFooterHint /> <RotatingFooterHint />
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
<AnimatePresence>
{refreshing && data && (
<motion.div
key="refresh-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
>
<motion.div
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
style={{ width: '40%' }}
/>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
function formatRelative(ts: number): string {
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 5) return '刚刚';
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
}
function HydrogenOverviewSkeleton() { function HydrogenOverviewSkeleton() {
return ( return (
<div className="flex flex-col gap-3 animate-pulse"> <div className="flex flex-col gap-3 animate-pulse">

View File

@@ -19,9 +19,12 @@ export interface HydrogenOverviewResponse {
year: number; year: number;
} }
export function fetchHydrogenOverview(year?: number): Promise<HydrogenOverviewResponse> { export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
const q = year ? `?year=${year}` : ''; const params = new URLSearchParams();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q}`); if (year) params.set('year', String(year));
if (force) params.set('force', '1');
const q = params.toString();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
} }
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> { export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {

View File

@@ -1,44 +1,135 @@
/** /**
* 简单 TTL 内存缓存 * SWR 缓存:始终返回热数据,后台定时刷新
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。 *
* 同一 key 并发请求只会触发一次 loader共享 in-flight Promise * 工作机制:
* - 首次请求:阻塞等待 loadercold start3-4s 不可避免)
* - 之后:每个 key 自调度刷新TTL 到期前 5s用户永远命中热缓存
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
* - 同一 key 并发请求只触发一次 loader
* - force=true手动强制刷新绕过缓存但仍参与 inflight 复用)
*/ */
interface Entry<T> { interface Entry<T> {
value: T; value: T;
freshAt: number;
expiresAt: number; expiresAt: number;
loader: () => Promise<T>;
lastAccess: number;
timer?: NodeJS.Timeout;
} }
const TTL_MS = 60 * 1000; const TTL_MS = 60 * 1000;
const REFRESH_LEAD_MS = 5 * 1000; // TTL 到期前多久触发刷新
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟无访问则停止调度
const RETRY_BACKOFF_MS = 10 * 1000; // loader 失败时重试间隔
const cache = new Map<string, Entry<unknown>>(); const cache = new Map<string, Entry<unknown>>();
const inflight = new Map<string, Promise<unknown>>(); const inflight = new Map<string, Promise<unknown>>();
export async function cached<T>(key: string, loader: () => Promise<T>): Promise<T> { function scheduleRefresh<T>(key: string, entry: Entry<T>) {
const now = Date.now(); if (entry.timer) clearTimeout(entry.timer);
const hit = cache.get(key); const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
if (hit && hit.expiresAt > now) { entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
return hit.value as T; entry.timer.unref?.();
} }
// 同一 key 并发只跑一次 loader async function runRefresh(key: string) {
const entry = cache.get(key) as Entry<unknown> | undefined;
if (!entry) return;
// 闲置超时:停止调度
if (Date.now() - entry.lastAccess > IDLE_TIMEOUT_MS) {
if (entry.timer) clearTimeout(entry.timer);
return;
}
if (inflight.has(key)) return;
const p = entry.loader()
.then(value => {
const now = Date.now();
const next: Entry<unknown> = {
value,
freshAt: now,
expiresAt: now + TTL_MS,
loader: entry.loader,
lastAccess: entry.lastAccess,
};
cache.set(key, next);
scheduleRefresh(key, next);
return value;
})
.catch(e => {
console.error(`[energy/cache] refresh failed for "${key}":`, e instanceof Error ? e.message : e);
// 保留旧值10s 后重试
const retry: Entry<unknown> = { ...entry };
retry.timer = setTimeout(() => { void runRefresh(key); }, RETRY_BACKOFF_MS);
retry.timer.unref?.();
cache.set(key, retry);
})
.finally(() => inflight.delete(key));
inflight.set(key, p);
}
export interface CachedOpts {
force?: boolean;
}
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
const now = Date.now();
const hit = cache.get(key) as Entry<T> | undefined;
if (hit) {
hit.lastAccess = now;
hit.loader = loader;
}
// 强制刷新:等待 loader 完成
if (opts.force) {
const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing;
const p = loader()
.then(value => {
const t = Date.now();
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
cache.set(key, next);
scheduleRefresh(key, next);
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, p as Promise<unknown>);
return p;
}
// 命中且未过期 → 立即返回
if (hit && hit.expiresAt > now) {
return hit.value;
}
// 命中但过期 → 返回 stale后台刷新
if (hit) {
if (!inflight.has(key)) void runRefresh(key);
return hit.value;
}
// 完全未命中 → 阻塞等待
const ongoing = inflight.get(key) as Promise<T> | undefined; const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing; if (ongoing) return ongoing;
const p = loader() const p = loader()
.then(value => { .then(value => {
cache.set(key, { value, expiresAt: Date.now() + TTL_MS }); const t = Date.now();
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
cache.set(key, entry);
scheduleRefresh(key, entry);
return value; return value;
}) })
.finally(() => { .finally(() => inflight.delete(key));
inflight.delete(key);
});
inflight.set(key, p as Promise<unknown>); inflight.set(key, p as Promise<unknown>);
return p; return p;
} }
/** 仅用于测试或调试:清空所有缓存 */ /** 仅用于测试或调试:清空所有缓存与定时器 */
export function _clearEnergyCache() { export function _clearEnergyCache() {
for (const e of cache.values()) {
if (e.timer) clearTimeout(e.timer);
}
cache.clear(); cache.clear();
inflight.clear(); inflight.clear();
} }

View File

@@ -61,6 +61,7 @@ function enumerateDates(range: Range): string[] {
// ========================================================= // =========================================================
app.get('/hydrogen/overview', async (c) => { app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year'); const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const today = new Date(); const today = new Date();
const todayYear = today.getFullYear(); const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear; const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
@@ -303,7 +304,7 @@ app.get('/hydrogen/overview', async (c) => {
})); }));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year }; return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
}); }, { force });
return c.json(data); return c.json(data);
}); });
@@ -313,6 +314,7 @@ app.get('/hydrogen/overview', async (c) => {
app.get('/hydrogen/daily', async (c) => { app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range; const range = (c.req.query('range') || 'last15') as Range;
const customer = (c.req.query('customer') || 'external') as CustomerKind; const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => { const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
@@ -423,7 +425,7 @@ app.get('/hydrogen/daily', async (c) => {
// 按日期降序返回 // 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date)); const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result; return result;
}); }, { force });
return c.json(data); return c.json(data);
}); });
@@ -431,6 +433,7 @@ app.get('/hydrogen/daily', async (c) => {
// 电能 总览KPI + 本月每日柱图数据 —— 数据源bi_ele_charge_record // 电能 总览KPI + 本月每日柱图数据 —— 数据源bi_ele_charge_record
// ========================================================= // =========================================================
app.get('/electric/overview', async (c) => { app.get('/electric/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('electric/overview', async () => { const data = await cached('electric/overview', async () => {
const [kpiRows] = await pool.query<RowDataPacket[]>( const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT `SELECT
@@ -504,7 +507,7 @@ app.get('/electric/overview', async (c) => {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct }, kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr, trend: trendArr,
}; };
}); }, { force });
return c.json(data); return c.json(data);
}); });
@@ -516,6 +519,7 @@ app.get('/electric/overview', async (c) => {
app.get('/electric/monthly', async (c) => { app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind; const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const range = (c.req.query('range') || 'last15') as Range; const range = (c.req.query('range') || 'last15') as Range;
const force = c.req.query('force') === '1';
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => { const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
@@ -590,7 +594,7 @@ app.get('/electric/monthly', async (c) => {
}); });
return months; return months;
}); }, { force });
return c.json(data); return c.json(data);
}); });