fix(HEL-412): 刷新降级不再整次失败,并补齐准备中提示

手动刷新与自动补跑共用可用数据判定:日线推算或上一交易日快照记为部分/准备中成功,避免前端误报刷新失败。HTTP JSON 解析错误不再把请求正文写入日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-02 18:08:40 +08:00
co-authored by Cursor multica-agent
parent 0d13066386
commit 5085cacf0d
22 changed files with 551 additions and 60 deletions
+2
View File
@@ -25,6 +25,7 @@ EMPTY_FAIL_DATASETS = {"stocks", "daily", "index_daily", "valuation", "moneyflow
def looks_like_heaven(module_name: str, filename: str = "") -> bool:
"""问天调用栈识别。问天未永久冻结,只是本阶段仍走旧 Tushare 链路。"""
path = filename.replace("\\", "/")
return module_name.startswith("backend.features.heaven") or "/features/heaven/" in path
@@ -95,6 +96,7 @@ class DatahubBridge:
legacy_query: Callable[..., list[dict[str, Any]]],
) -> list[dict[str, Any]]:
dataset = API_TO_DATASET.get(api_name)
# 问天允许后续纳入 datahub;首批只读接入仍保持旧链路,避免误切。
if not dataset or self.heaven_guard():
return legacy_query(api_name, params, fields)
flags = self.settings.flags(dataset)
+79 -10
View File
@@ -79,6 +79,8 @@ class MarketServiceMixin:
if not force:
snapshot = self.database.get_snapshot(normalized_date)
if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
if self._should_retry_incomplete_snapshot(snapshot, normalized_date):
return self.sync_dashboard(normalized_date)
snapshot = copy.deepcopy(snapshot)
if normalized_date != now.strftime("%Y%m%d"):
snapshot.setdefault("meta", {}).update(
@@ -97,6 +99,8 @@ class MarketServiceMixin:
"dashboard_request_v1", normalized_date
)
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
if self._should_retry_incomplete_snapshot(resolved, normalized_date):
return self.sync_dashboard(normalized_date)
resolved = copy.deepcopy(resolved)
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
normalized_date
@@ -138,6 +142,68 @@ class MarketServiceMixin:
def _display_compact_date(compact: str) -> str:
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
@staticmethod
def _chinese_month_day(value: str) -> str:
compact = str(value or "").replace("-", "").replace("/", "")
if len(compact) < 8 or not compact[:8].isdigit():
return "最近可用交易日"
return f"{int(compact[4:6])}{int(compact[6:8])}"
@classmethod
def _preparing_display_notice(cls, actual_date: str, requested_date: str) -> str:
shown = cls._chinese_month_day(actual_date)
requested = str(requested_date or "").replace("-", "")
if requested == date.today().strftime("%Y%m%d"):
return f"今日数据正在准备,当前展示 {shown}"
return f"所选日期数据尚未到齐,当前展示 {shown}"
@staticmethod
def _snapshot_age_seconds(meta: dict[str, Any]) -> float:
raw = str(meta.get("updated_at") or "")
if not raw:
return 10**9
try:
updated_at = datetime.fromisoformat(raw)
except ValueError:
return 10**9
now = datetime.now().astimezone()
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=now.tzinfo)
return (now - updated_at.astimezone(now.tzinfo)).total_seconds()
def _should_retry_incomplete_snapshot(
self, snapshot: dict[str, Any], requested_date: str
) -> bool:
if requested_date != date.today().strftime("%Y%m%d"):
return False
meta = snapshot.get("meta") or {}
incomplete = (
meta.get("limit_data_source") == "derived"
or bool(meta.get("carried_forward"))
or str(meta.get("trade_date") or "").replace("-", "") != requested_date
)
return incomplete and self._snapshot_age_seconds(meta) >= 60
def _annotate_data_status(self, dashboard: dict[str, Any]) -> dict[str, Any]:
meta = dashboard.setdefault("meta", {})
notice = str(meta.get("notice") or "")
requested = str(meta.get("requested_date") or "").replace("-", "")
actual = str(meta.get("trade_date") or "").replace("-", "")
if meta.get("limit_data_source") == "derived" and not meta.get("carried_forward"):
meta["data_status"] = "partial"
meta["display_notice"] = notice or "部分正式数据尚未到齐,当前展示日线推算结果"
elif meta.get("carried_forward"):
if "非交易日" in notice or "盘前" in notice:
meta["data_status"] = "carried"
meta["display_notice"] = notice
else:
meta["data_status"] = "preparing"
meta["display_notice"] = self._preparing_display_notice(actual, requested)
else:
meta["data_status"] = "official"
meta.setdefault("display_notice", "")
return dashboard
def _carry_dashboard(
self, snapshot: dict[str, Any], requested_date: str, reason: str
) -> dict[str, Any]:
@@ -152,7 +218,7 @@ class MarketServiceMixin:
"notice": reason,
}
)
return carried
return self._annotate_data_status(carried)
def _realtime_snapshot_due(
self,
@@ -197,14 +263,14 @@ class MarketServiceMixin:
if not self.configured:
raise TushareError("公共行情尚未配置")
dashboard = self._tushare_client().dashboard(normalized_date)
if (dashboard.get("meta") or {}).get("limit_data_source") == "derived":
raise TushareError(
str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回")
meta = dashboard.setdefault("meta", {})
meta["source"] = source
meta["requested_date"] = self._display_compact_date(normalized_date)
if meta.get("limit_data_source") == "derived":
meta.setdefault(
"notice",
"涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
)
dashboard["meta"]["source"] = source
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
record_count = self._record_count(dashboard)
actual_date = normalize_date(
@@ -233,8 +299,11 @@ class MarketServiceMixin:
except TushareError as exc:
fallback = self.database.get_latest_real_snapshot(normalized_date)
if fallback:
actual = str((fallback.get("meta") or {}).get("trade_date") or "")
carried = self._carry_dashboard(
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
fallback,
normalized_date,
self._preparing_display_notice(actual, normalized_date),
)
self.database.finish_sync(
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
@@ -1160,7 +1229,7 @@ class MarketServiceMixin:
"storage": "sqlite",
"cached": cached,
}
return result
return self._annotate_data_status(result)
@staticmethod
def _record_count(dashboard: dict[str, Any]) -> int:
+8 -1
View File
@@ -109,7 +109,14 @@ class HttpTransportMixin:
return {}
if length <= 0 or length > 65536:
raise ValueError("请求内容为空或过大。")
return json.loads(self.rfile.read(length).decode("utf-8"))
raw = self.rfile.read(length)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise ValueError("请求不是合法 JSON。") from None
if not isinstance(payload, dict):
raise ValueError("请求不是合法 JSON。")
return payload
def serve_static(self, request_path: str) -> None:
relative = unquote(request_path).lstrip("/") or "index.html"
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime, time as dt_time
def dashboard_has_usable_data(dashboard: dict[str, object]) -> bool:
if not isinstance(dashboard, dict) or dashboard.get("status") == "failed":
return False
meta = dashboard.get("meta") or {}
overview = dashboard.get("overview") or {}
if isinstance(meta, dict) and (meta.get("trade_date") or meta.get("carried_forward")):
return True
return bool(isinstance(overview, dict) and overview)
def verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]:
"""Manual refresh and automatic catch-up share this rule.
Derived limit lists or a previous usable snapshot are not whole-job failures.
Only a payload with no displayable market data is recorded as failed.
"""
if dashboard_has_usable_data(dashboard):
return dashboard
meta = dashboard.get("meta") if isinstance(dashboard, dict) else None
notice = ""
if isinstance(meta, dict):
notice = str(meta.get("notice") or meta.get("display_notice") or "")
return {
"status": "failed",
"error": notice or "未获取到可用行情",
}
def official_catchup_due(today: str, snapshot: dict[str, object]) -> bool:
now = datetime.now().astimezone().time().replace(tzinfo=None)
if not (dt_time(15, 5) <= now < dt_time(22, 0)):
return False
meta = snapshot.get("meta") if isinstance(snapshot.get("meta"), dict) else {}
actual = str(meta.get("trade_date") or "").replace("-", "")
if (
actual == today
and meta.get("limit_data_source") != "derived"
and not meta.get("carried_forward")
):
return False
return True
+11 -12
View File
@@ -5,16 +5,7 @@ import time
from datetime import date
from backend.bootstrap.config import normalize_date
def _verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]:
meta = dashboard.get("meta") or {}
if isinstance(meta, dict) and meta.get("carried_forward"):
return {
"status": "failed",
"error": str(meta.get("notice") or "未获取到所选日期的最新行情"),
}
return dashboard
from backend.jobs.refresh import official_catchup_due, verified_dashboard_result
class JobServiceMixin:
@@ -36,7 +27,7 @@ class JobServiceMixin:
started = self.jobs.submit(
"market.refresh",
key,
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)),
lambda: verified_dashboard_result(self.sync_dashboard(normalized)),
{"trade_date": normalized, "trigger": "administrator"},
)
return {"started": started, "job_key": key if started else ""}
@@ -54,7 +45,15 @@ class JobServiceMixin:
self.jobs.submit(
"market.refresh",
f"realtime:{today}:{bucket}",
lambda: self.sync_dashboard(today),
lambda: verified_dashboard_result(self.sync_dashboard(today)),
{"trade_date": today, "trigger": "realtime-poll"},
)
elif official_catchup_due(today, snapshot):
bucket = int(time.time() // 300)
self.jobs.submit(
"market.refresh",
f"catchup:{today}:{bucket}",
lambda: verified_dashboard_result(self.sync_dashboard(today)),
{"trade_date": today, "trigger": "official-catchup"},
)
self._schedule_automatic_screeners(today, snapshot)