perf: 优化热点穿透加载速度 + Canvas 渲染 + 触控板/手机交互
后端加载优化(盘中原本每次访问都重新聚合,耗时 10-30 秒): - 图聚合结果与题材股票子层盘中加 60 秒缓存 - 缓存过期时返回旧数据并后台幂等重建(stale-while-revalidate),打开即秒开 - 缓存基础支持秒级 TTL(set_cache 新增 ttl_seconds) - 响应体瘦身:移除 edges(前端从 stocks[].themeCodes 重建)、 themes 精简字段、stocks 按 limit 裁剪,JSON 从数 MB 降至数百 KB 前端 Canvas 渲染重构: - d3-force 布局保留,SVG 渲染层替换为 Canvas 双缓冲(静止态离屏层 drawImage) - tick 由每帧 setState 改为 rAF 合帧,拖拽/缩放期间零 React 重渲染,800 节点流畅 交互优化: - 手机:新增 +/−/适应 缩放按钮、命中半径放大至 22px、tap/drag 6px 阈值区分、 双指捏合缩放、触屏禁用 hover 避免与选中冲突 - Mac 触控板:双指滚动=平移、捏合(ctrlKey)=缩放、滚轮缩放保留、点空白取消选中 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,9 @@ def get_cache(key: str) -> Optional[str]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_cache(key: str, value: str, ttl_hours: int = 6):
|
||||
"""写入缓存,过期时间 = now + ttl_hours"""
|
||||
expires_at = (datetime.now() + timedelta(hours=ttl_hours)).isoformat()
|
||||
def set_cache(key: str, value: str, ttl_hours: int = 6, ttl_seconds: int = 0):
|
||||
"""写入缓存,过期时间 = now + ttl_hours + ttl_seconds(支持秒级短 TTL)"""
|
||||
expires_at = (datetime.now() + timedelta(hours=ttl_hours, seconds=ttl_seconds)).isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
|
||||
+111
-26
@@ -217,9 +217,9 @@ async def fetch_theme_stocks(theme_code: str) -> dict:
|
||||
|
||||
result = {"stockList": stock_list, "statistic": statistic, "total": total}
|
||||
if stock_list:
|
||||
ttl = _dynamic_ttl()
|
||||
if ttl > 0:
|
||||
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=ttl)
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return result
|
||||
|
||||
|
||||
@@ -227,14 +227,45 @@ async def fetch_theme_stocks(theme_code: str) -> dict:
|
||||
|
||||
# 合并采样:涨幅榜 Top N + 热度榜 Top N 去重合并,
|
||||
# 避免单一榜单导致股票覆盖题材数被低估(如有研新材覆盖 10+ 题材,仅涨幅榜只能采到 2 个)。
|
||||
_GRAPH_MERGE_TOP = 50
|
||||
|
||||
# 盘中图聚合结果/题材股票子层的缓存秒数。交易时段数据波动快,用 60s 短缓存;
|
||||
# 非交易时段缓存 18 小时(覆盖到下一交易日)。
|
||||
_GRAPH_CACHE_SECONDS = 60
|
||||
|
||||
|
||||
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict:
|
||||
"""构建题材-股票网状关系图数据(热点穿透)
|
||||
def _graph_ttl_seconds() -> int:
|
||||
"""图聚合结果与题材股票子层的缓存秒数:盘中 60 秒,非盘中 18 小时"""
|
||||
return _GRAPH_CACHE_SECONDS if _is_trading_time() else 18 * 3600
|
||||
|
||||
合并采样涨幅榜 + 热度榜 Top N 题材(去重),并发拉取每题材全部股票,
|
||||
统计每股覆盖的题材数(M:N 关系),返回前端可直接渲染的图结构。
|
||||
|
||||
# 后台重建锁:cache_key -> asyncio.Lock,幂等去重,防止并发重复聚合
|
||||
_REBUILD_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _set_graph_cache(cache_key: str, data: dict) -> None:
|
||||
"""写入图聚合缓存(存 data + built_at + expires_at,epoch 秒)"""
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s <= 0:
|
||||
return
|
||||
now = time.time()
|
||||
entry = {"data": data, "built_at": now, "expires_at": now + ttl_s}
|
||||
set_cache(cache_key, json.dumps(entry, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
|
||||
|
||||
def _get_graph_cache(cache_key: str) -> tuple[Optional[dict], Optional[float]]:
|
||||
"""读取图聚合缓存,返回 (data, expires_at);无缓存/损坏返回 (None, None)"""
|
||||
cached = get_cache(cache_key)
|
||||
if cached is None:
|
||||
return None, None
|
||||
try:
|
||||
entry = json.loads(cached)
|
||||
return entry.get("data"), entry.get("expires_at")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None, None
|
||||
|
||||
|
||||
async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
|
||||
"""构建完整图数据(全量统计,边由前端从 stocks[].themeCodes 重建)
|
||||
|
||||
Args:
|
||||
sort_field: 题材排序 1=涨幅, 4=热度(当前榜在前,另一榜合并补充)
|
||||
@@ -242,17 +273,11 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict:
|
||||
|
||||
Returns:
|
||||
{
|
||||
"themes": [{themeCode, themeName, bf3, hotValue, strengthValue, stockCount}],
|
||||
"themes": [{themeCode, themeName, stockCount}],
|
||||
"stocks": [{securityCode, securityName, coverCount, f3, f2, f62, f100, themeCodes[]}],
|
||||
"edges": [{themeCode, securityCode}],
|
||||
"stats": {themeCount, stockCount, coreCount, maxCover}
|
||||
}
|
||||
"""
|
||||
cache_key = f"theme_graph:{sort_field}:{top_n}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
# 1. 合并采样涨幅榜 + 热度榜(当前榜优先在前,另一榜补充),按 themeCode 去重
|
||||
primary_list = await fetch_theme_list(sort_field, asc=False)
|
||||
other_field = 4 if sort_field == 1 else 1
|
||||
@@ -264,7 +289,7 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict:
|
||||
merged.setdefault(t["themeCode"], t)
|
||||
themes = list(merged.values())
|
||||
if not themes:
|
||||
return {"themes": [], "stocks": [], "edges": [], "stats": {}}
|
||||
return {"themes": [], "stocks": [], "stats": {}}
|
||||
|
||||
# 2. 并发拉取每题材股票(限流保护)
|
||||
sem = asyncio.Semaphore(5)
|
||||
@@ -275,17 +300,15 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict:
|
||||
|
||||
results = await asyncio.gather(*[_fetch_with_limit(t["themeCode"]) for t in themes])
|
||||
|
||||
# 3. 构建图数据
|
||||
# 3. 构建 M:N 关系:统计每股覆盖的题材数
|
||||
theme_map = {t["themeCode"]: t for t in themes}
|
||||
stock_map: dict[str, dict] = {} # securityCode -> stock dict
|
||||
edges: list[dict] = []
|
||||
|
||||
for t, res in zip(themes, results):
|
||||
stock_list = res.get("stockList", [])
|
||||
theme_map[t["themeCode"]]["stockCount"] = len(stock_list)
|
||||
for s in stock_list:
|
||||
code = s["securityCode"]
|
||||
edges.append({"themeCode": t["themeCode"], "securityCode": code})
|
||||
if code not in stock_map:
|
||||
stock_map[code] = {
|
||||
"securityCode": code,
|
||||
@@ -310,14 +333,76 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict:
|
||||
"maxCover": max((s["coverCount"] for s in stocks), default=1),
|
||||
}
|
||||
|
||||
result = {
|
||||
"themes": list(theme_map.values()),
|
||||
return {
|
||||
"themes": [
|
||||
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]}
|
||||
for t in theme_map.values()
|
||||
],
|
||||
"stocks": stocks,
|
||||
"edges": edges,
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
ttl = _dynamic_ttl()
|
||||
if ttl > 0:
|
||||
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=ttl)
|
||||
return result
|
||||
|
||||
def _trim_graph_result(result: dict, limit: int) -> dict:
|
||||
"""按 limit 裁剪 stocks(保留穿透度最高的 N 只),仅影响下发体积,不影响 coverCount 统计"""
|
||||
return {
|
||||
"themes": result.get("themes", []),
|
||||
"stocks": result.get("stocks", [])[:limit],
|
||||
"stats": result.get("stats", {}),
|
||||
}
|
||||
|
||||
|
||||
def _spawn_rebuild(cache_key: str, sort_field: int, top_n: int) -> None:
|
||||
"""幂等触发后台重建:已有重建任务在跑则跳过"""
|
||||
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
|
||||
if lock.locked():
|
||||
return
|
||||
asyncio.create_task(_rebuild_task(cache_key, sort_field, top_n, lock))
|
||||
|
||||
|
||||
async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: asyncio.Lock) -> None:
|
||||
"""后台重建:拿锁后 double-check 缓存是否已被刷新,避免重复聚合"""
|
||||
async with lock:
|
||||
try:
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
return # 已被其他任务刷新
|
||||
data = await _build_theme_graph(sort_field, top_n)
|
||||
_set_graph_cache(cache_key, data)
|
||||
print(f"[themes] graph 后台重建完成: {cache_key}")
|
||||
except Exception as e:
|
||||
print(f"[themes] graph 后台重建失败: {cache_key} {e}")
|
||||
|
||||
|
||||
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
|
||||
"""获取热点穿透图数据(盘中 60s 缓存 + stale-while-revalidate)
|
||||
|
||||
缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开);
|
||||
无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。
|
||||
|
||||
Args:
|
||||
sort_field: 题材排序 1=涨幅, 4=热度
|
||||
top_n: 每个榜单的题材数量(1-60)
|
||||
limit: 下发 stocks 上限(穿透度最高的 N 只)
|
||||
"""
|
||||
cache_key = f"theme_graph:{sort_field}:{top_n}"
|
||||
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None:
|
||||
# 有缓存:新鲜直接返回;过期返回旧数据并后台刷新
|
||||
if not (expires_at and expires_at > time.time()):
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
# 无缓存:同步构建(并发下加锁去重)
|
||||
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
|
||||
async with lock:
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None:
|
||||
# 等待锁期间已被其他请求写入
|
||||
if not (expires_at and expires_at > time.time()):
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
return _trim_graph_result(data, limit)
|
||||
data = await _build_theme_graph(sort_field, top_n)
|
||||
_set_graph_cache(cache_key, data)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
Reference in New Issue
Block a user