修复数据链路 P0 瓶颈:缓存炸弹、Context 重渲染、LGBM 循环、TTL 不匹配

P0-1 sessionStorage 限制:
- writeCityDetailCacheBundle 只保留最近 3 个城市的详情
- 避免 3-10MB JSON 序列化阻塞主线程

P0-2 Context 拆分:
- 新增 CityDetailsContext 独立管理 cityDetailsByName 变更
- 新增 useCityDetails hook,只订阅详情的组件不再因 cities/proAccess 变化重渲染
- DashboardStoreContext 保持不变,向后兼容

P0-3 扫描终端 TTL:
- SCAN_TERMINAL_PAYLOAD_TTL_SEC 30s → 120s
- 匹配 ThreadPoolExecutor(4) x 60 城的实际重算耗时

P0-4 LGBM 循环依赖:
- LGBM 预测值不再作为 DEB 输入参与权重计算
- 保留为独立参考字段 lgbm.prediction 输出给前端展示
- 消除 DEB → LGBM 训练 → DEB 的循环

新增 docs/data-architecture-review.md 完整数据链路审查报告

Tested: npx tsc --noEmit, python -m ruff check .
This commit is contained in:
2569718930@qq.com
2026-05-10 16:48:28 +08:00
parent 8cc7e9a996
commit c0bb2acf78
5 changed files with 233 additions and 16 deletions
+24 -1
View File
@@ -73,6 +73,12 @@ interface DashboardStoreValue extends DashboardState {
}
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
const CityDetailsContext = createContext<{
cityDetailsByName: Record<string, CityDetail>;
cityDetailMetaByName: Record<string, { cachedAt: number; revision: string }>;
citySummariesByName: Record<string, CitySummary>;
loadingState: LoadingState;
} | null>(null);
function getInitialLoadingState(): LoadingState {
return {
@@ -1520,9 +1526,16 @@ export function DashboardStoreProvider({
],
);
const cityDetailsValue = useMemo(
() => ({ cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState }),
[cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState],
);
return (
<DashboardStoreContext.Provider value={value}>
{children}
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
</DashboardStoreContext.Provider>
);
}
@@ -1537,6 +1550,16 @@ export function useDashboardStore() {
return context;
}
export function useCityDetails() {
const context = useContext(CityDetailsContext);
if (!context) {
throw new Error(
"useCityDetails must be used within DashboardStoreProvider",
);
}
return context;
}
export function useCityData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
+12 -8
View File
@@ -542,15 +542,19 @@ export const dashboardClient = {
meta: Record<string, CityCacheMeta>,
) {
if (!isClient()) return;
// Keep only the 3 most-recently-accessed cities to prevent sessionStorage bloat
const MAX_CACHED_CITIES = 3;
const allEntries = Object.entries(details).map(([cityName, detail]) => ({
cityName,
cachedAt: meta[cityName]?.cachedAt || 0,
detail,
revision: meta[cityName]?.revision || getCityRevision(detail),
}));
const topEntries = allEntries
.sort((a, b) => b.cachedAt - a.cachedAt)
.slice(0, MAX_CACHED_CITIES);
const entries = Object.fromEntries(
Object.entries(details).map(([cityName, detail]) => [
cityName,
{
cachedAt: meta[cityName]?.cachedAt || Date.now(),
detail,
revision: meta[cityName]?.revision || getCityRevision(detail),
},
]),
topEntries.map((e) => [e.cityName, { cachedAt: e.cachedAt, detail: e.detail, revision: e.revision }]),
);
window.sessionStorage.setItem(
CACHE_KEY,