fix(web): v1.19.3 K线空body不再500 + SPA路由刷新404

两个独立问题(日志证据):

1. K线空body 500:SH600519 等正常股票偶发请求时,通达信返回
   ret_count>0 但 body 完全为空(偏移2 剩余0)。v1.18.3 的容错有
   'if bars:' 条件,bars 为空时 raise → 500。移除该条件,始终 return
   (空列表让前端分页重试比 500 友好)。GetIndexBarsCmd 同改。

2. SPA fallback 缺失:/optimize /portfolio 等前端路由刷新时 404。
   子类化 StaticFiles 为 SPAStaticFiles,404 时返回 index.html。
   API 路径不受影响。

测试:更新 test_security_bars_truncated_first_record(原断言 raise,
现断言返回空列表)+ 新增空 body 回归测试。902 全过。
This commit is contained in:
Justin Gu
2026-07-07 20:13:39 +08:00
parent 6b51487477
commit 032f5a40c0
6 changed files with 86 additions and 31 deletions
+23 -23
View File
@@ -80,19 +80,21 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos)
except TdxDecodeError as e:
# TDX 服务端偶发截断:响应头声称有 N 条,但 body 末尾若干条
# 被切掉(停牌/退市/分页边界常见)。丢弃残缺的末条,保留已
# 成功解析的前若干条,避免一条坏数据让整页 500。
if bars:
_log.warning(
"K线响应在第 %d/%d 条处被截断(%s),已丢弃末尾残缺记录,返回前 %d",
i + 1,
ret_count,
e,
len(bars),
)
return bars
raise
# TDX 服务端偶发截断或空响应:响应头声称有 N 条,但 body
# 末尾若干条被切掉,甚至整条 body 除了 ret_count 头外为空。
# 两种情况都丢弃残缺部分,返回已成功解析的前若干条,避免
# 一条坏数据让整页 500。
# 注意:即使 bars 为空(第 1 条就崩)也 return 而非 raise ——
# 服务器返回 0 条数据但 ret_count 撒谎是已知现象,返回空列表
# 让调用方分页重试比直接 500 更友好。
_log.warning(
"K线响应在第 %d/%d 条处被截断(%s),已丢弃末尾残缺记录,返回前 %d",
i + 1,
ret_count,
e,
len(bars),
)
return bars
# 差分还原(与 pytdx 完全一致)
open_abs = open_diff + pre_diff_base
@@ -151,16 +153,14 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE
pos += 4
except TdxDecodeError as e:
if bars:
_log.warning(
"指数K线响应在第 %d/%d 条处被截断(%s),已丢弃末尾残缺记录,返回前 %d",
i + 1,
ret_count,
e,
len(bars),
)
return bars
raise
_log.warning(
"指数K线响应在第 %d/%d 条处被截断(%s),已丢弃末尾残缺记录,返回前 %d",
i + 1,
ret_count,
e,
len(bars),
)
return bars
# 差分还原(与 pytdx 完全一致)
open_abs = open_diff + pre_diff_base
+30 -2
View File
@@ -248,8 +248,36 @@ def _create_app(
dist_dir = _resolve_web_dist_dir()
if dist_dir is not None:
app.mount("/", StaticFiles(directory=str(dist_dir), html=True), name="web-ui")
logger.info("Web UI mounted from %s", dist_dir)
# SPA fallback:前端用 createWebHistoryHTML5 history 模式),
# 用户直接访问 /optimize、/portfolio 等前端路由或刷新时,后端必须
# 返回 index.html 让 Vue Router 接管,而不是 404。
# 实现方式:用 StaticFiles 挂在 "/static" 提供真实文件(JS/CSS/图标),
# 再加一个 catch-all 路由把所有非 /api、非 /static 的 GET 请求导向
# index.html。但这样会改变 JS/CSS 的 URL 前缀(/assets → /static/assets),
# 需要改 vite base 配置,代价大。
# 更简单的方式:先尝试 StaticFiles 服务真实文件,找不到时 fallback。
# Starlette 的 StaticFiles(html=True) 不做 SPA fallback,故子类化它。
from pathlib import Path as _Path
from starlette.responses import FileResponse
class SPAStaticFiles(StaticFiles):
"""StaticFiles + SPA fallback404 时返回 index.html。"""
async def get_response(self, path: str, scope): # type: ignore[no-untyped-def]
try:
return await super().get_response(path, scope)
except Exception:
# 任何 404(路径非文件)都返回 index.html,让前端路由处理。
# 仅对 GET 请求生效;API 路径 (/api/v1/*) 已在前面注册,
# 不会走到这里。
index = _Path(str(self.directory)) / "index.html"
if index.is_file():
return FileResponse(str(index))
raise
app.mount("/", SPAStaticFiles(directory=str(dist_dir), html=True), name="web-ui")
logger.info("Web UI mounted from %s (SPA fallback enabled)", dist_dir)
else:
logger.info("Web UI dist not found — serving API only")