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
+9
View File
@@ -2,6 +2,15 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.19.3] — 2026-07-07
**修复 EXE 运行时两个问题:K 线空 body 仍 500 + 前端路由刷新 404** —— v1.19.2 在实际机器上运行日志暴露两个问题:(1) SH600519 等正常股票偶发请求 K 线时,通达信服务器返回 `ret_count>0` 但 body 完全为空(pos=2 剩余 0 字节),v1.18.3 的容错有 `if bars:` 条件——bars 为空时走 `raise` → 500,老人看到"取行情失败"。(2) 用户在 `/optimize``/portfolio` 等前端路由页面刷新时,后端 StaticFiles 找不到文件返回 404SPA fallback 缺失)。
### 修复
- **K 线空 body 不再 500**`src/easy_tdx/commands/security_bars.py`)—— 移除 `if bars:` 条件,无论已解析条数多少,`TdxDecodeError``return bars`(空列表让前端分页重试比直接 500 友好)。日志证据:`偏移 2,实际剩余 0 字节` = body 只有 ret_count 头、第 1 条 datetime 就崩。`GetIndexBarsCmd`(指数 K 线)同改。更新 `test_security_bars_truncated_first_record_still_raises`(原断言 raise,现断言返回空列表)+ 新增 `test_security_bars_ret_count_lies_body_completely_empty` 回归守卫。
- **SPA fallback**`src/easy_tdx/web/app.py`)—— 子类化 `StaticFiles``SPAStaticFiles`404 时返回 `index.html` 让 Vue Router 接管。修复 `/optimize``/portfolio``/compare``/strategies` 等前端路由刷新 404。API 路径(`/api/v1/*`)已在路由表注册,不受影响。
## [1.19.2] — 2026-07-07
**修复干净 Windows 上 EXE 双击后页面纯黑** —— v1.19.1 在没装开发工具的 Windows(如老人电脑)上双击 EXE,浏览器打开 `localhost:8000` 后页面纯黑、`/docs` 却能正常打开。根因:干净 Windows 的注册表里没有 `.js` 文件的 `Content Type` 映射,Python 的 `mimetypes.guess_type('.js')` 返回 `None`FastAPI/Starlette 的 `StaticFiles` 回退到 `text/plain`。但 `index.html` 里的 `<script type="module">` 启用严格 MIME 检查,浏览器拒绝执行 `text/plain` 的 JS(报错 `Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/plain"`),Vue 根本不挂载 → 纯黑。修复:在 mount 前用 `mimetypes.add_type` 强制注册 `.js/.mjs/.css/.svg` 的正确 MIME,无论机器装没装开发工具都生效。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.19.2"
version = "1.19.3"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+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")
+9 -5
View File
@@ -144,10 +144,14 @@ def test_security_bars_truncated_drops_partial_last_record():
assert len(bars) == 4 # 前 4 条完整,末条残缺被丢弃
def test_security_bars_truncated_first_record_still_raises():
"""若连第一条都无法解析(body 完全没有记录数据),仍抛 TdxDecodeError。"""
def test_security_bars_truncated_first_record_returns_empty():
"""若连第一条都无法解析(body 完全没有记录数据),返回空列表而非抛异常。
v1.19.2 实测:SH600519 等正常股票偶发返回 ret_count>0 但 body 为空,
服务器侧问题。v1.18.3 的容错有 ``if bars:`` 条件导致此场景仍 raise → 500,
老人看到"取行情失败"。改为始终 return(空列表让前端分页重试比 500 好)。
"""
from easy_tdx.commands.security_bars import GetSecurityBarsCmd
from easy_tdx.exceptions import TdxDecodeError
from easy_tdx.models.enums import KlineCategory, Market
body = load_hex("security_bars")
@@ -157,8 +161,8 @@ def test_security_bars_truncated_first_record_still_raises():
truncated = struct.pack("<H", 5) + truncated[2:]
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory.DAY, 0, 5)
with pytest.raises(TdxDecodeError):
cmd.parse_response(truncated)
bars = cmd.parse_response(truncated)
assert bars == []
# ---------------------------------------------------------------------------
+14
View File
@@ -97,3 +97,17 @@ def test_security_bars_complete_body_not_affected() -> None:
bars = cmd.parse_response(body)
assert len(bars) == 2
assert (bars[1].year, bars[1].month, bars[1].day) == (2024, 1, 2)
def test_security_bars_ret_count_lies_body_completely_empty() -> None:
"""ret_count 撒谎说有数据但 body 只有 ret_count 头(0 条记录数据)。
回归 v1.19.2 日志报错:SH600519 请求 count=800,服务器返回 ret_count=5
但 body 从 pos=2 开始就为空,第 1 条 datetime 解析即崩(偏移 2,剩余 0)。
v1.18.3 的容错有 ``if bars:`` 条件,bars 为空时走 ``raise`` → 500。
修复后无论 bars 是否为空都 return(空列表让调用方重试比 500 好)。
"""
body = struct.pack("<H", 5) # ret_count=5,但 0 字节记录数据
cmd = GetSecurityBarsCmd(Market.SH, "600519", KlineCategory.DAY, 0, 800)
bars = cmd.parse_response(body)
assert bars == []