revert: 移除拼音声母搜索,回到 6 位代码输入

拼音搜索的底层依赖(首次需爬沪深 A 股 5000 条全名单,几十次 TDX 协议
往返,慢机器几十秒到超时)太重,反复优化(按需加载/遮罩/单飞/预热)
都无法兼顾'不阻塞核心行情请求'与'首次可用'。用户决定放弃此功能,
回到简单稳定的 6 位代码输入。

回退 e2bf29e..2523605 共 6 个 commit 的全部改动:
- 删除 StockSearchInput / AppInitOverlay / useStockSearch
- 移除 pypinyin 依赖、/security/search-index 端点、lifespan 预热
- SymbolPicker / StocksPicker 恢复为纯 6 位代码输入
- README / CHANGELOG 同步回退

代码状态等同 v1.18.1(一键寻优多进程并发)发布后的干净基线
This commit is contained in:
Justin Gu
2026-07-06 02:50:51 +08:00
parent 25236056f7
commit c9d80617e8
13 changed files with 48 additions and 668 deletions
-18
View File
@@ -64,24 +64,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
ex_client = None
app.state.ex_client = ex_client
# --- 股票搜索索引预热(后台,不阻塞服务启动) ---
# 首次 get_security_list_all("all") 要几十次 TDX 协议往返(几十秒),
# 后台提前跑,让本地缓存尽早建立。用户打开页面时大概率已就绪。
# 仅触发构建(fire-and-forget),不 await 不超时——预热是 best effort
# 没有权力取消构建 task(否则会波及同时到达的 /security/search-index 请求)。
import asyncio
async def _warmup_security_list() -> None:
try:
from easy_tdx.web.routers.market import _build_search_index
await _build_search_index(client)
logger.info("Security list warmup done")
except Exception:
logger.warning("Security list warmup failed (non-fatal)", exc_info=True)
asyncio.create_task(_warmup_security_list())
yield
# --- 依次关闭 ---
-86
View File
@@ -53,92 +53,6 @@ async def security_list_all(
return _df_response(df)
# ── 股票搜索索引(声母检索) ───────────────────────────────────────────────────
# 进程级缓存:5206 条记录的 {code, name, initials} 只算一次,热重启即丢。
# 前端拉一次后模块级缓存,按 code/name.includes/initials.includes 三路过滤。
_SEARCH_INDEX: list[dict[str, str]] | None = None
# 单飞标记:True 表示后台构建 task 正在跑。调用方据此判断是否需要启动新 task。
# 注意:不持有 task/future 引用,避免调用方被 cancel 时波及后台构建。
_SEARCH_BUILDING: bool = False
# 后台构建失败的最近一次异常(供等待中的调用方读取;None 表示无错或未发生)
_SEARCH_BUILD_ERROR: BaseException | None = None
async def _build_search_index(client: Any) -> list[dict[str, str]]:
"""构建搜索索引:拉全名单 + pypinyin 预计算声母。耗时几十秒(首次)。
单飞 + 轮询设计:后台构建 task 与调用方解耦,调用方被 cancel(如预热超时)
不会波及正在跑的构建 task,也不会让其他等待的请求收到 CancelledError。
"""
import asyncio
global _SEARCH_INDEX, _SEARCH_BUILDING, _SEARCH_BUILD_ERROR
# 已就绪:直接返回
if _SEARCH_INDEX is not None:
return _SEARCH_INDEX
# 未启动构建:启动后台 taskfire and forget,调用方不持有它的引用)
if not _SEARCH_BUILDING:
_SEARCH_BUILDING = True
_SEARCH_BUILD_ERROR = None
async def _do_build() -> None:
global _SEARCH_INDEX, _SEARCH_BUILDING, _SEARCH_BUILD_ERROR
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
except BaseException as e:
_SEARCH_BUILD_ERROR = e
finally:
_SEARCH_BUILDING = False
asyncio.create_task(_do_build())
# 轮询等待结果(每 0.5s 检查一次)。
# 这样调用方被 cancel 时,只是退出轮询,不影响后台 _do_build task。
# 用 asyncio.shield 保护轮询本身不被取消传播,并在每次循环检查错误。
for _ in range(600): # 上限 300 秒(600 × 0.5s
if _SEARCH_INDEX is not None:
return _SEARCH_INDEX
if not _SEARCH_BUILDING and _SEARCH_BUILD_ERROR is not None:
# 构建已结束但失败:抛错给调用方(下次调用会重新触发构建)
err = _SEARCH_BUILD_ERROR
_SEARCH_BUILD_ERROR = None
raise err
await asyncio.sleep(0.5)
raise TimeoutError("搜索索引构建超时(300s")
@router.get("/security/search-index")
async def security_search_index(
client: Any = Depends(get_client),
) -> dict[str, Any]:
"""返回股票搜索索引 ``[{code, name, initials}]``(供前端声母/代码/名字搜索)。
数据源复用 :meth:`get_security_list_all`(沪深 A 股,已有本地日级缓存)。
声母用 pypinyin ``FIRST_LETTER`` 预计算(如 中际旭创→zjxc)。
进程内缓存,首次请求算一次后常驻;强制刷新重启进程即可。
与 lifespan 预热共享同一单飞任务(``_build_search_index``),
避免预热和首次端点请求并发各爬一次全名单。
"""
if _SEARCH_INDEX is not None:
return {"count": len(_SEARCH_INDEX), "data": _SEARCH_INDEX}
index = await _build_search_index(client)
return {"count": len(index), "data": index}
@router.post("/quotes", response_model=DataFrameResponse)
async def security_quotes(
req: QuoteRequest,