fix(backend): 清除数据彻底清空三层内存缓存 + 监控触发记录 + 修复版本号

修复清除数据后看板/监控仍显示旧数据的根因:

数据缓存清理 (repository/screener/overview):
- repository.py: 新增 clear_cache() 无条件清空全部 Polars 内存缓存;
  修复 _refresh_enriched() 磁盘无数据时不清内存导致旧数据残留的 bug
- screener.py: 新增 clear_history_cache() 清进程级 _history_cache (TTL 缓存)
- overview.py: 新增 invalidate_overview_cache() 清看板聚合结果 5s TTL 缓存

清除数据完整性 (data.py):
- clear_data 删除 parquet 后统一调用三层缓存清理
- 新增清除监控运行数据: alert_store.clear() 清 alerts.jsonl + _pending_alerts 内存队列
  (原 clear_data 漏删 user_data/alerts.jsonl, 导致触发记录清不掉)

版本号统一:
- /api/data/version 改为优先读 app.__version__ (与 /health 同源),
  原 pyproject.toml 路径解析有误读不到, 回退到陈旧的 VERSION 文件
- Windows GBK 编码修复: __init__.py 强制 stdout/stderr UTF-8,
  避免 TickFlow SDK 输出含 emoji 的指数名称时崩溃 (gbk codec can't encode)
This commit is contained in:
shy3130
2026-06-24 23:24:04 +08:00
parent 2ea2844917
commit 37f4d61e3a
5 changed files with 83 additions and 14 deletions
+12
View File
@@ -1,3 +1,15 @@
"""TickFlow Stock Panel backend."""
import sys
__version__ = "0.1.38"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
# 进程加载最早阶段强制 UTF-8,根治此类编码崩溃。
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception: # noqa: BLE001
pass
+32 -14
View File
@@ -564,9 +564,31 @@ def clear_data(request: Request):
fp.unlink()
deleted += 1
# 清除监控运行数据 (user_data 下仅清运行产物, 不动 monitor_rules/preferences/secrets 等用户配置)
# - 触发记录 alerts.jsonl
from app.services import alert_store
alert_store.clear(data_dir)
# - 待推送的实时通知队列 (进程内存)
qs = getattr(request.app.state, "quote_service", None)
if qs is not None:
with qs._lock:
qs._pending_alerts.clear()
# 清除 Polars 缓存
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
# 再 refresh_cache 尝试重载 (磁盘有数据则重建缓存)。
repo.clear_cache()
repo.refresh_cache()
# 清除 Screener 进程级 _history_cache (TTL 缓存)
from app.services.screener import ScreenerService
ScreenerService.clear_history_cache()
# 清除 Overview 总览聚合结果缓存 (5s TTL)
from app.api.overview import invalidate_overview_cache
invalidate_overview_cache()
# 刷新 DuckDB 视图(空 parquet 目录也需要重新挂载)
d = data_dir.as_posix()
for name, path in {
@@ -703,23 +725,19 @@ def table_schema(request: Request, table: str) -> list[dict]:
def get_version(request: Request) -> dict:
"""返回当前项目版本号。
优先从 pyproject.toml 读取 (项目权威版本),
回退到 VERSION 文件, 最后兜底 v0.0.0。
优先读 app.__version__ (与 /health 接口同源, 唯一权威版本),
回退到项目根 VERSION 文件, 最后兜底 v0.0.0。
"""
from app import __version__
# 1. 优先用 app.__version__ (开发期 bump_version.py 写入, 打包期由 PyInstaller 注入)
if __version__:
v = __version__.strip()
return {"version": v if v.startswith("v") else f"v{v}"}
# 2. 回退到项目根 VERSION 文件
from app.config import settings
project_root = Path(settings.data_dir).parent
# 1. 优先读 pyproject.toml
pyproject = project_root / "pyproject.toml"
if pyproject.exists():
for line in pyproject.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("version"):
# version = "0.1.28"
v = line.split("=", 1)[1].strip().strip('"').strip("'")
if v:
return {"version": f"v{v}" if not v.startswith("v") else v}
# 2. 回退到 VERSION 文件
version_file = project_root / "VERSION"
if version_file.exists():
v = version_file.read_text(encoding="utf-8").strip()
+12
View File
@@ -20,6 +20,18 @@ _cache: dict[str, Any] | None = None
_cache_key: str | None = None
_cache_ts: float = 0.0
def invalidate_overview_cache() -> None:
"""清空总览聚合结果缓存。
清除数据后调用, 避免看板在 TTL 窗口内继续返回旧的聚合结果。
"""
global _cache, _cache_key, _cache_ts
_cache = None
_cache_key = None
_cache_ts = 0.0
CORE_INDEX_NAMES = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
+8
View File
@@ -185,6 +185,14 @@ class ScreenerService:
def __init__(self, repo: KlineRepository) -> None:
self.repo = repo
@staticmethod
def clear_history_cache() -> None:
"""清空进程级 _history_cache (TTL 缓存)。
清除数据后调用, 避免内存里的旧历史窗口残留导致策略/看板仍命中旧数据。
"""
_history_cache.clear()
def _load_enriched_for_date(self, target_date: date) -> pl.DataFrame:
"""从 enriched parquet 读取指定日期的基础数据并即时计算完整指标+信号。
+19
View File
@@ -152,6 +152,22 @@ class KlineRepository:
self._refresh_index_instruments()
self._refresh_enriched()
def clear_cache(self) -> None:
"""清空所有 Polars 内存缓存。
与 refresh_cache 的区别: refresh_cache 在磁盘无数据时会提前 return,
导致内存里的旧缓存残留 (clear 数据后看板仍显示旧数据的根因)。
本方法无条件清空, 供清除数据/重置场景调用。
"""
self._enriched_cache = None
self._enriched_cache_date = None
self._enriched_history_cache = None
self._enriched_history_start = None
self._live_agg_cache = None
self._live_agg_cache_date = None
self._instruments_cache = None
self._index_instruments_cache = None
def _refresh_enriched(self) -> None:
"""从 parquet 加载 enriched 最新日到内存 + 构建聚合表。
@@ -163,6 +179,9 @@ class KlineRepository:
try:
latest = self._latest_enriched_date_duckdb()
if not latest:
# 磁盘已无数据: 必须清空内存缓存, 否则旧数据会残留
# (清数据后看板仍显示旧数据的根因)
self.clear_cache()
return
# Step 1: 直接读最新日期的分区文件 (仅 14 列)