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
+1
View File
@@ -74,5 +74,6 @@ python -c "from pathlib import Path; from datahub.db import HubDB; HubDB(Path('d
## 安全
- 密钥只以 `configured / 末4位 / 更新时间` 出现在后台,不进日志、不进 `/v1`
- HTTP 解析失败只记录“请求不是合法 JSON”,不把请求正文、密码或 Token 写入容器日志
- 回滚、补数需重新输入密码 + 确认词
- 容器非 rootuid 10002)、read_only、cap_drop ALL
+3 -1
View File
@@ -145,7 +145,9 @@ class TushareAdapter(MarketAdapter):
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
result = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
except json.JSONDecodeError:
raise AdapterError("Tushare returned invalid json") from None
except (urllib.error.URLError, TimeoutError) as exc:
raise AdapterError(f"Tushare request failed: {exc}") from exc
if result.get("code") != 0:
raise AdapterError(result.get("msg") or "Tushare returned an unknown error")
+9 -1
View File
@@ -190,7 +190,15 @@ class HubRequestHandler(BaseHTTPRequestHandler):
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):
LOGGER.warning("invalid json request body")
raise ValueError("请求不是合法 JSON") from None
if not isinstance(payload, dict):
raise ValueError("请求不是合法 JSON")
return payload
def _cookie_value(self, name: str) -> str:
cookie = SimpleCookie()
+28 -2
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import json
import logging
import re
import sys
import traceback
from typing import Any
from datahub.timeutil import isoformat
@@ -11,6 +13,13 @@ _SECRET_KEYS = (
"token", "password", "secret", "key", "authorization", "credential",
"tushare_token", "datahub_token", "encryption_key", "cookie",
)
_SECRET_JSON = re.compile(
r'(?i)("(?:' + "|".join(re.escape(key) for key in _SECRET_KEYS) + r')"\s*:\s*")([^"\\]*(?:\\.[^"\\]*)*)(")'
)
def redact_log_text(text: str) -> str:
return _SECRET_JSON.sub(r"\1***\3", str(text))
def _redact(value: Any, key: str = "") -> Any:
@@ -21,22 +30,39 @@ def _redact(value: Any, key: str = "") -> Any:
return {str(item_key): _redact(item_value, str(item_key)) for item_key, item_value in value.items()}
if isinstance(value, list):
return [_redact(item) for item in value]
if isinstance(value, str):
return redact_log_text(value)
return value
def _safe_exc_text(exc_info: tuple[Any, Any, Any]) -> str:
exc = exc_info[1]
if isinstance(exc, json.JSONDecodeError):
return f"JSONDecodeError: invalid json at position {exc.pos}"
cause = getattr(exc, "__cause__", None)
if isinstance(cause, json.JSONDecodeError):
return f"{type(exc).__name__}: invalid json in request"
text = "".join(traceback.format_exception(*exc_info))
if isinstance(cause, json.JSONDecodeError) and cause.doc:
text = text.replace(cause.doc, "")
if isinstance(exc, json.JSONDecodeError) and exc.doc:
text = text.replace(exc.doc, "")
return redact_log_text(text)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"message": redact_log_text(record.getMessage()),
}
extra = getattr(record, "hub", None)
if isinstance(extra, dict):
payload.update(_redact(extra))
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
payload["exc"] = _safe_exc_text(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
+51
View File
@@ -1,17 +1,21 @@
from __future__ import annotations
import io
import json
import logging
import tempfile
import threading
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from datahub.adapters.tushare import TushareAdapter
from datahub.crypto import SecretVault
from datahub.httpapp import make_handler
from datahub.hub import Hub
from datahub.logutil import JsonFormatter
from datahub.settings import Settings
from tests.fixtures import fake_transport
@@ -91,6 +95,53 @@ class AdminTests(unittest.TestCase):
)
self.assertEqual(ctx.exception.code, 401)
def test_invalid_json_does_not_log_request_body_secrets(self) -> None:
secret = "SuperSecretPass1!"
token = "hub-token-should-not-leak"
raw = json.dumps({"password": secret, "token": token, "username": "hub_admin"}) + "{not-json"
stream = io.StringIO()
logger = logging.getLogger("datahub")
handler = logging.StreamHandler(stream)
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
previous_level = logger.level
logger.setLevel(logging.DEBUG)
try:
req = Request(
self.base + "/admin/api/login",
data=raw.encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with self.assertRaises(HTTPError) as ctx:
urlopen(req, timeout=5)
body = ctx.exception.read().decode("utf-8")
self.assertEqual(ctx.exception.code, 400)
self.assertNotIn(secret, body)
self.assertNotIn(token, body)
blob = stream.getvalue() + body
self.assertNotIn(secret, blob)
self.assertNotIn(token, blob)
self.assertNotIn(raw, blob)
finally:
logger.removeHandler(handler)
logger.setLevel(previous_level)
def test_json_formatter_drops_decode_error_document(self) -> None:
secret = "ParseSecretTokenXYZ"
formatter = JsonFormatter()
logger = logging.getLogger("datahub.test")
record = logger.makeRecord(
"datahub.test", logging.ERROR, __file__, 1, "parse failed", (), None
)
try:
json.loads('{"password": "%s"}{' % secret)
except json.JSONDecodeError as exc:
record.exc_info = (type(exc), exc, exc.__traceback__)
blob = formatter.format(record)
self.assertNotIn(secret, blob)
self.assertIn("invalid json", blob)
if __name__ == "__main__":
unittest.main()