From 04fd720a05d5ef9d8a4ee9e9c2209ccef91ab44c Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Mon, 6 Jul 2026 02:31:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(web-ui):=20=E6=90=9C=E7=B4=A2=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E5=89=8D=E7=AB=AF=E8=B6=85=E6=97=B6=2015s=E2=86=92120?= =?UTF-8?q?s=20+=20=E5=90=8E=E7=AB=AF=E5=8D=95=E9=A3=9E=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:另一台机器首次加载报 'signal is aborted without reason'—— 不是取不到数据,是前端 AbortController 15 秒就掐断了请求,但遮罩 却告诉用户等 30-60 秒,自相矛盾。 修复: - 前端 fetchSearchIndex 超时 15s → 120s:遮罩本就是要等的,不能自己掐断 - 后端 _build_search_index 单飞去重:用 asyncio.Future 让 lifespan 预热 与 /security/search-index 端点请求共享同一任务,避免并发各爬一次全名单 (两个任务抢同一把 _execute_lock 反而互相拖慢) - lifespan 预热改为调 _build_search_index(复用单飞),加 120s 超时保护 --- src/easy_tdx/web/app.py | 6 ++- src/easy_tdx/web/routers/market.py | 68 +++++++++++++++++++++++------- web-ui/src/api.ts | 6 +-- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index f917d42..010d787 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -68,11 +68,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # 首次 get_security_list_all("all") 要几十次 TDX 协议往返(几十秒), # 后台提前跑,让本地缓存尽早建立。用户打开页面时大概率已就绪。 # 用 create_task 不 await,服务立即可用;预热未完成时前端遮罩接管等待。 + # 与 /security/search-index 端点共享同一单飞任务(_build_search_index), + # 避免预热和首次端点请求并发各爬一次全名单。 import asyncio async def _warmup_security_list() -> None: try: - await client.get_security_list_all(pages="all") + from easy_tdx.web.routers.market import _build_search_index + + await asyncio.wait_for(_build_search_index(client), timeout=120) logger.info("Security list warmup done") except Exception: logger.warning("Security list warmup failed (non-fatal)", exc_info=True) diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index 45b197f..20ee270 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -57,6 +57,55 @@ async def security_list_all( # 进程级缓存:5206 条记录的 {code, name, initials} 只算一次,热重启即丢。 # 前端拉一次后模块级缓存,按 code/name.includes/initials.includes 三路过滤。 _SEARCH_INDEX: list[dict[str, str]] | None = None +# 单飞 Future:预热(lifespan)与端点请求共享同一任务,避免并发爬两次全名单。 +# 首次请求会 await 这个 Future;后续请求命中 _SEARCH_INDEX 直接返回。 +_SEARCH_INDEX_TASK: Any = None # asyncio.Future[list[dict[str, str]]] + + +async def _build_search_index(client: Any) -> list[dict[str, str]]: + """构建搜索索引:拉全名单 + pypinyin 预计算声母。耗时几十秒(首次)。""" + import asyncio + + global _SEARCH_INDEX, _SEARCH_INDEX_TASK + # 双重检查:等待期间可能已被其他协程填好 + if _SEARCH_INDEX is not None: + return _SEARCH_INDEX + # 单飞:已有进行中的任务则复用,避免预热 + 端点请求并发爬两次 + if _SEARCH_INDEX_TASK is None: + _SEARCH_INDEX_TASK = asyncio.get_running_loop().create_future() + + async def _do_build() -> None: + global _SEARCH_INDEX, _SEARCH_INDEX_TASK + from pypinyin import Style, lazy_pinyin + + try: + df = await client.get_security_list_all(pages="all") + index: list[dict[str, str]] = [] + for row in df.itertuples(index=False): + name = str(getattr(row, "name", "") or "") + if not name: + continue + code = str(getattr(row, "code", "")) + initials = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER)) + index.append({"code": code, "name": name, "initials": initials}) + _SEARCH_INDEX = index + if not _SEARCH_INDEX_TASK.done(): + _SEARCH_INDEX_TASK.set_result(index) + except Exception as e: + # 失败清空 task,允许下次重试 + if not _SEARCH_INDEX_TASK.done(): + _SEARCH_INDEX_TASK.set_exception(e) + _SEARCH_INDEX_TASK = None + raise + finally: + # 成功后清空 task 引用(结果已存 _SEARCH_INDEX) + _SEARCH_INDEX_TASK = None + + asyncio.create_task(_do_build()) + + from typing import cast + + return cast("list[dict[str, str]]", await _SEARCH_INDEX_TASK) @router.get("/security/search-index") @@ -68,24 +117,13 @@ async def security_search_index( 数据源复用 :meth:`get_security_list_all`(沪深 A 股,已有本地日级缓存)。 声母用 pypinyin ``FIRST_LETTER`` 预计算(如 中际旭创→zjxc)。 进程内缓存,首次请求算一次后常驻;强制刷新重启进程即可。 + + 与 lifespan 预热共享同一单飞任务(``_build_search_index``), + 避免预热和首次端点请求并发各爬一次全名单。 """ - global _SEARCH_INDEX if _SEARCH_INDEX is not None: return {"count": len(_SEARCH_INDEX), "data": _SEARCH_INDEX} - - from pypinyin import Style, lazy_pinyin - - df = await client.get_security_list_all(pages="all") - index: list[dict[str, str]] = [] - for row in df.itertuples(index=False): - name = str(getattr(row, "name", "") or "") - if not name: - continue - code = str(getattr(row, "code", "")) - initials = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER)) - index.append({"code": code, "name": name, "initials": initials}) - - _SEARCH_INDEX = index + index = await _build_search_index(client) return {"count": len(index), "data": index} diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index bded2ee..ea6ad28 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -105,11 +105,11 @@ export async function fetchBars( /** 拉取股票搜索索引(code/name/initials,约 5000 条,~150KB)。 * 前端 useStockSearch 会模块级缓存,整个会话只拉一次。 - * 超时 15 秒放弃——后端首次构建索引要走全量 get_security_list_all(几十秒), - * 超时后放弃可避免长时间独占共享连接、阻塞 /bars 行情请求。 */ + * 超时 120 秒——后端首次构建索引要走全量 get_security_list_all(几十秒), + * AppInitOverlay 遮罩期间用户本就在等待,不能过早 abort。 */ export async function fetchSearchIndex(): Promise { const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), 15_000) + const timer = setTimeout(() => controller.abort(), 120_000) try { const resp = await fetch(`${BASE}/security/search-index`, { signal: controller.signal }) if (!resp.ok) await throwError(resp)