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:
co-authored by
Cursor
multica-agent
parent
0d13066386
commit
5085cacf0d
@@ -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,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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user