mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 22:44:22 +08:00
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:
@@ -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
@@ -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:前端用 createWebHistory(HTML5 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 fallback:404 时返回 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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user