diff --git a/backend/api/main.py b/backend/api/main.py
index d1b104c..6c2c10b 100644
--- a/backend/api/main.py
+++ b/backend/api/main.py
@@ -90,6 +90,32 @@ def setup_logging():
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
+ # 追加:将 ERROR 日志写入 Redis(不影响现有文件/控制台日志)
+ try:
+ from api.redis_log_handler import RedisErrorLogHandler, RedisLogConfig
+
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
+ redis_use_tls = os.getenv("REDIS_USE_TLS", "False").lower() == "true"
+ redis_username = os.getenv("REDIS_USERNAME", None)
+ redis_password = os.getenv("REDIS_PASSWORD", None)
+ ssl_cert_reqs = os.getenv("REDIS_SSL_CERT_REQS", "required")
+ ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
+
+ redis_cfg = RedisLogConfig(
+ redis_url=redis_url,
+ use_tls=redis_use_tls,
+ username=redis_username,
+ password=redis_password,
+ ssl_cert_reqs=ssl_cert_reqs,
+ ssl_ca_certs=ssl_ca_certs,
+ service="backend",
+ )
+ redis_handler = RedisErrorLogHandler(redis_cfg)
+ redis_handler.setLevel(logging.ERROR)
+ root_logger.addHandler(redis_handler)
+ except Exception:
+ pass
+
# 设置第三方库的日志级别
logging.getLogger('uvicorn').setLevel(logging.WARNING)
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)
diff --git a/backend/api/redis_log_handler.py b/backend/api/redis_log_handler.py
new file mode 100644
index 0000000..f739675
--- /dev/null
+++ b/backend/api/redis_log_handler.py
@@ -0,0 +1,186 @@
+"""
+FastAPI backend:将 ERROR 日志写入 Redis List(仅保留最近 N 条)。
+
+实现与 trading_system/redis_log_handler.py 保持一致(避免跨目录导入带来的 PYTHONPATH 问题)。
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import socket
+import time
+import traceback
+from dataclasses import dataclass
+from datetime import datetime, timezone, timedelta
+from typing import Any, Dict, Optional
+
+
+def _beijing_time_str(ts: float) -> str:
+ beijing_tz = timezone(timedelta(hours=8))
+ return datetime.fromtimestamp(ts, tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _safe_json_loads(s: str) -> Optional[Dict[str, Any]]:
+ try:
+ obj = json.loads(s)
+ if isinstance(obj, dict):
+ return obj
+ except Exception:
+ return None
+ return None
+
+
+@dataclass(frozen=True)
+class RedisLogConfig:
+ redis_url: str
+ list_key: str = "ats:logs:error"
+ max_len: int = 2000
+ dedupe_consecutive: bool = True
+ service: str = "backend"
+ hostname: str = socket.gethostname()
+ connect_timeout_sec: float = 1.0
+ socket_timeout_sec: float = 1.0
+ username: Optional[str] = None
+ password: Optional[str] = None
+ use_tls: bool = False
+ ssl_cert_reqs: str = "required"
+ ssl_ca_certs: Optional[str] = None
+
+
+class RedisErrorLogHandler(logging.Handler):
+ def __init__(self, cfg: RedisLogConfig):
+ super().__init__()
+ self.cfg = cfg
+ self._redis = None
+ self._redis_ok = False
+ self._last_connect_attempt_ts = 0.0
+
+ def _connection_kwargs(self) -> Dict[str, Any]:
+ kwargs: Dict[str, Any] = {
+ "decode_responses": True,
+ "socket_connect_timeout": self.cfg.connect_timeout_sec,
+ "socket_timeout": self.cfg.socket_timeout_sec,
+ }
+ if self.cfg.username:
+ kwargs["username"] = self.cfg.username
+ if self.cfg.password:
+ kwargs["password"] = self.cfg.password
+
+ if self.cfg.redis_url.startswith("rediss://") or self.cfg.use_tls:
+ kwargs["ssl_cert_reqs"] = self.cfg.ssl_cert_reqs
+ if self.cfg.ssl_ca_certs:
+ kwargs["ssl_ca_certs"] = self.cfg.ssl_ca_certs
+ if self.cfg.ssl_cert_reqs == "none":
+ kwargs["ssl_check_hostname"] = False
+ elif self.cfg.ssl_cert_reqs == "required":
+ kwargs["ssl_check_hostname"] = True
+ else:
+ kwargs["ssl_check_hostname"] = False
+ return kwargs
+
+ def _get_redis(self):
+ now = time.time()
+ if self._redis_ok and self._redis is not None:
+ return self._redis
+ if now - self._last_connect_attempt_ts < 5:
+ return None
+ self._last_connect_attempt_ts = now
+
+ try:
+ import redis # type: ignore
+ except Exception:
+ self._redis = None
+ self._redis_ok = False
+ return None
+
+ try:
+ client = redis.from_url(self.cfg.redis_url, **self._connection_kwargs())
+ client.ping()
+ self._redis = client
+ self._redis_ok = True
+ return self._redis
+ except Exception:
+ self._redis = None
+ self._redis_ok = False
+ return None
+
+ def _build_entry(self, record: logging.LogRecord) -> Dict[str, Any]:
+ msg = record.getMessage()
+ exc_text = None
+ exc_type = None
+ if record.exc_info:
+ exc_type = getattr(record.exc_info[0], "__name__", None)
+ exc_text = "".join(traceback.format_exception(*record.exc_info))
+
+ signature = f"{self.cfg.service}|{record.levelname}|{record.name}|{record.pathname}:{record.lineno}|{msg}|{exc_type or ''}"
+
+ return {
+ "ts": int(record.created * 1000),
+ "time": _beijing_time_str(record.created),
+ "service": self.cfg.service,
+ "level": record.levelname,
+ "logger": record.name,
+ "message": msg,
+ "pathname": record.pathname,
+ "lineno": record.lineno,
+ "funcName": record.funcName,
+ "process": record.process,
+ "thread": record.thread,
+ "hostname": self.cfg.hostname,
+ "exc_type": exc_type,
+ "exc_text": exc_text,
+ "signature": signature,
+ "count": 1,
+ }
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ client = self._get_redis()
+ if client is None:
+ return
+
+ entry = self._build_entry(record)
+ list_key = os.getenv("REDIS_LOG_LIST_KEY", self.cfg.list_key).strip() or self.cfg.list_key
+ max_len = int(os.getenv("REDIS_LOG_LIST_MAX_LEN", str(self.cfg.max_len)) or self.cfg.max_len)
+ if max_len <= 0:
+ max_len = self.cfg.max_len
+
+ if self.cfg.dedupe_consecutive:
+ try:
+ head_raw = client.lindex(list_key, 0)
+ except Exception:
+ head_raw = None
+
+ if isinstance(head_raw, str):
+ head = _safe_json_loads(head_raw)
+ else:
+ head = None
+
+ if head and head.get("signature") == entry["signature"]:
+ head["count"] = int(head.get("count", 1)) + 1
+ head["ts"] = entry["ts"]
+ head["time"] = entry["time"]
+ if entry.get("exc_text"):
+ head["exc_text"] = entry.get("exc_text")
+ head["exc_type"] = entry.get("exc_type")
+ try:
+ pipe = client.pipeline()
+ pipe.lset(list_key, 0, json.dumps(head, ensure_ascii=False))
+ pipe.ltrim(list_key, 0, max_len - 1)
+ pipe.execute()
+ return
+ except Exception:
+ pass
+
+ try:
+ pipe = client.pipeline()
+ pipe.lpush(list_key, json.dumps(entry, ensure_ascii=False))
+ pipe.ltrim(list_key, 0, max_len - 1)
+ pipe.execute()
+ except Exception:
+ return
+ except Exception:
+ return
+
diff --git a/backend/api/routes/system.py b/backend/api/routes/system.py
index 045dbd2..d579631 100644
--- a/backend/api/routes/system.py
+++ b/backend/api/routes/system.py
@@ -12,6 +12,123 @@ logger = logging.getLogger(__name__)
# 路由统一挂在 /api/system 下,前端直接调用 /api/system/...
router = APIRouter(prefix="/api/system")
+def _get_redis_client_for_logs():
+ """
+ 获取 Redis 客户端(优先复用 config_manager 的连接;失败则自行创建)。
+ 返回:redis.Redis 或 None
+ """
+ # 1) 复用 config_manager(避免重复连接)
+ try:
+ import config_manager # backend/config_manager.py(已负责加载 .env)
+
+ cm = getattr(config_manager, "config_manager", None)
+ if cm is not None:
+ redis_client = getattr(cm, "_redis_client", None)
+ redis_connected = getattr(cm, "_redis_connected", False)
+ if redis_client is not None and redis_connected:
+ try:
+ redis_client.ping()
+ return redis_client
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # 2) 自行创建
+ try:
+ import redis # type: ignore
+
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
+ redis_use_tls = os.getenv("REDIS_USE_TLS", "False").lower() == "true"
+ redis_username = os.getenv("REDIS_USERNAME", None)
+ redis_password = os.getenv("REDIS_PASSWORD", None)
+ ssl_cert_reqs = os.getenv("REDIS_SSL_CERT_REQS", "required")
+ ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
+
+ kwargs: Dict[str, Any] = {
+ "decode_responses": True,
+ "username": redis_username,
+ "password": redis_password,
+ "socket_connect_timeout": 1,
+ "socket_timeout": 1,
+ }
+ if redis_url.startswith("rediss://") or redis_use_tls:
+ kwargs["ssl_cert_reqs"] = ssl_cert_reqs
+ if ssl_ca_certs:
+ kwargs["ssl_ca_certs"] = ssl_ca_certs
+ if ssl_cert_reqs == "none":
+ kwargs["ssl_check_hostname"] = False
+ elif ssl_cert_reqs == "required":
+ kwargs["ssl_check_hostname"] = True
+ else:
+ kwargs["ssl_check_hostname"] = False
+
+ client = redis.from_url(redis_url, **kwargs)
+ client.ping()
+ return client
+ except Exception:
+ return None
+
+
+@router.get("/logs")
+async def get_logs(
+ limit: int = 200,
+ service: Optional[str] = None,
+ level: Optional[str] = None,
+ x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
+) -> Dict[str, Any]:
+ """
+ 从 Redis List 读取最新日志(默认 ats:logs:error)。
+
+ 参数:
+ - limit: 返回条数(最大 2000)
+ - service: 过滤(backend / trading_system)
+ - level: 过滤(ERROR / CRITICAL ...)
+ """
+ _require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
+
+ if limit <= 0:
+ limit = 200
+ if limit > 2000:
+ limit = 2000
+
+ list_key = os.getenv("REDIS_LOG_LIST_KEY", "ats:logs:error").strip() or "ats:logs:error"
+
+ client = _get_redis_client_for_logs()
+ if client is None:
+ raise HTTPException(status_code=503, detail="Redis 不可用,无法读取日志")
+
+ try:
+ raw_items = client.lrange(list_key, 0, limit - 1)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"读取 Redis 日志失败: {e}")
+
+ items: list[Dict[str, Any]] = []
+ for raw in raw_items or []:
+ try:
+ obj = raw
+ if isinstance(raw, bytes):
+ obj = raw.decode("utf-8", errors="ignore")
+ if isinstance(obj, str):
+ parsed = __import__("json").loads(obj)
+ else:
+ continue
+ if not isinstance(parsed, dict):
+ continue
+ if service and str(parsed.get("service")) != service:
+ continue
+ if level and str(parsed.get("level")) != level:
+ continue
+ items.append(parsed)
+ except Exception:
+ continue
+
+ return {
+ "key": list_key,
+ "count": len(items),
+ "items": items,
+ }
+
def _require_admin(token: Optional[str], provided: Optional[str]) -> None:
"""
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 3afeb88..cf7249c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -5,6 +5,7 @@ import ConfigGuide from './components/ConfigGuide'
import TradeList from './components/TradeList'
import StatsDashboard from './components/StatsDashboard'
import Recommendations from './components/Recommendations'
+import LogMonitor from './components/LogMonitor'
import './App.css'
function App() {
@@ -19,6 +20,7 @@ function App() {
交易推荐
配置
交易记录
+ 日志监控
@@ -30,6 +32,7 @@ function App() {
{it.exc_text}
+