diff --git a/backend/api/main.py b/backend/api/main.py index 71e016f..13e8e04 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -3,7 +3,7 @@ FastAPI应用主入口 """ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routes import config, trades, stats, dashboard, account, recommendations, system, accounts, auth, admin +from api.routes import config, trades, stats, dashboard, account, recommendations, system, accounts, auth, admin, public import os import logging from pathlib import Path @@ -141,12 +141,12 @@ logger.info(f"日志级别: {os.getenv('LOG_LEVEL', 'INFO')}") # 检查 redis-py 是否可用(redis-py 4.2+ 同时支持同步和异步,可替代aioredis) try: - import redis + import redis # type: ignore # 检查是否是 redis-py 4.2+(支持异步) if hasattr(redis, 'asyncio'): logger.info(f"✓ redis-py 已安装 (版本: {redis.__version__ if hasattr(redis, '__version__') else '未知'}),支持同步和异步客户端") - logger.info(f" - redis.Redis: 同步客户端(用于config_manager)") - logger.info(f" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)") + logger.info(" - redis.Redis: 同步客户端(用于config_manager)") + logger.info(" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)") else: logger.warning("⚠ redis-py 版本可能过低,建议升级到 4.2+ 以获得异步支持") except ImportError as e: @@ -154,9 +154,9 @@ except ImportError as e: logger.warning("⚠ redis-py 未安装,Redis/Valkey 缓存将不可用") logger.warning(f" Python 路径: {sys.executable}") logger.warning(f" 导入错误: {e}") - logger.warning(f" 提示: 请运行 'pip install redis>=4.2.0' 安装 redis-py") - logger.warning(f" 注意: redis-py 4.2+ 同时支持同步和异步,无需安装 aioredis") - logger.warning(f" 或者运行 'pip install -r backend/requirements.txt' 安装所有依赖") + logger.warning(" 提示: 请运行 'pip install redis>=4.2.0' 安装 redis-py") + logger.warning(" 注意: redis-py 4.2+ 同时支持同步和异步,无需安装 aioredis") + logger.warning(" 或者运行 'pip install -r backend/requirements.txt' 安装所有依赖") app = FastAPI( title="Auto Trade System API", @@ -228,6 +228,7 @@ app.include_router(dashboard.router, prefix="/api/dashboard", tags=["仪表板"] app.include_router(account.router, prefix="/api/account", tags=["账户数据"]) app.include_router(recommendations.router, tags=["交易推荐"]) app.include_router(system.router, tags=["系统控制"]) +app.include_router(public.router) @app.get("/") diff --git a/backend/api/routes/public.py b/backend/api/routes/public.py new file mode 100644 index 0000000..260b32b --- /dev/null +++ b/backend/api/routes/public.py @@ -0,0 +1,183 @@ +""" +公开只读状态接口(非管理员也可访问) + +用途: +- 普通用户能看到:后端是否在线、启动时间、推荐是否在更新(snapshot 时间) +- recommendations-viewer 也可复用该接口展示“服务状态” + +安全原则: +- 不返回任何敏感信息(不返回密钥、密码、完整 Redis URL 等) +""" + +from __future__ import annotations + +import json +import os +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Tuple + +from fastapi import APIRouter + +try: + import redis.asyncio as redis_async +except Exception: # pragma: no cover + redis_async = None + + +router = APIRouter(prefix="/api/public", tags=["public"]) + +_STARTED_AT_MS = int(time.time() * 1000) + +REDIS_KEY_RECOMMENDATIONS_SNAPSHOT = "recommendations:snapshot" + + +def _beijing_time_str(ts_ms: Optional[int] = None) -> str: + beijing_tz = timezone(timedelta(hours=8)) + if ts_ms is None: + return datetime.now(tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S") + return datetime.fromtimestamp(ts_ms / 1000, tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S") + + +def _mask_redis_url(redis_url: str) -> str: + s = (redis_url or "").strip() + if not s: + return "" + # 简单脱敏:去掉 username/password(如果有) + # rediss://user:pass@host:6379/0 -> rediss://***@host:6379/0 + if "://" in s and "@" in s: + scheme, rest = s.split("://", 1) + creds_and_host = rest + # 仅替换 @ 前面的内容 + idx = creds_and_host.rfind("@") + if idx > 0: + return f"{scheme}://***@{creds_and_host[idx+1:]}" + return s + + +def _redis_connection_kwargs() -> Tuple[str, Dict[str, Any]]: + redis_url = (os.getenv("REDIS_URL", "") or "").strip() or "redis://localhost:6379" + username = os.getenv("REDIS_USERNAME", None) + password = os.getenv("REDIS_PASSWORD", None) + ssl_cert_reqs = (os.getenv("REDIS_SSL_CERT_REQS", "required") or "required").strip() + ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None) + + select = os.getenv("REDIS_SELECT", None) + try: + select_i = int(select) if select is not None else 0 + except Exception: + select_i = 0 + + kwargs: Dict[str, Any] = {"decode_responses": True} + if username: + kwargs["username"] = username + if password: + kwargs["password"] = password + kwargs["db"] = select_i + + use_tls = redis_url.startswith("rediss://") or (os.getenv("REDIS_USE_TLS", "False").lower() == "true") + if use_tls and not redis_url.startswith("rediss://"): + if redis_url.startswith("redis://"): + redis_url = redis_url.replace("redis://", "rediss://", 1) + else: + redis_url = f"rediss://{redis_url}" + + if use_tls or redis_url.startswith("rediss://"): + kwargs["ssl_cert_reqs"] = ssl_cert_reqs + if ssl_ca_certs: + kwargs["ssl_ca_certs"] = ssl_ca_certs + kwargs["ssl_check_hostname"] = (ssl_cert_reqs == "required") + + return redis_url, kwargs + + +async def _get_redis(): + if redis_async is None: + return None + redis_url, kwargs = _redis_connection_kwargs() + try: + client = redis_async.from_url(redis_url, **kwargs) + await client.ping() + return client + except Exception: + return None + + +async def _get_cached_json(client, key: str) -> Optional[Any]: + try: + raw = await client.get(key) + if not raw: + return None + return json.loads(raw) + except Exception: + return None + + +@router.get("/status") +async def public_status(): + """ + 公共状态: + - backend:在线/启动时间 + - redis:可用性(不暴露密码) + - recommendations:snapshot 最新生成时间(若推荐进程在跑,会持续更新) + """ + now_ms = int(time.time() * 1000) + + # Redis + 推荐快照 + redis_ok = False + reco: Dict[str, Any] = {"snapshot_ok": False} + redis_meta: Dict[str, Any] = {"ok": False, "db": int(os.getenv("REDIS_SELECT", "0") or 0), "url": _mask_redis_url(os.getenv("REDIS_URL", ""))} + + rds = await _get_redis() + if rds is not None: + redis_ok = True + redis_meta["ok"] = True + try: + snap = await _get_cached_json(rds, REDIS_KEY_RECOMMENDATIONS_SNAPSHOT) + except Exception: + snap = None + + if isinstance(snap, dict): + gen_ms = snap.get("generated_at_ms") + try: + gen_ms = int(gen_ms) if gen_ms is not None else None + except Exception: + gen_ms = None + count = snap.get("count") + try: + count = int(count) if count is not None else None + except Exception: + count = None + age_sec = None + if gen_ms: + age_sec = max(0, int((now_ms - gen_ms) / 1000)) + reco = { + "snapshot_ok": True, + "generated_at_ms": gen_ms, + "generated_at": snap.get("generated_at"), + "generated_at_beijing": _beijing_time_str(gen_ms) if gen_ms else None, + "age_sec": age_sec, + "count": count, + "ttl_sec": snap.get("ttl_sec"), + } + + try: + await rds.close() + except Exception: + pass + + return { + "backend": { + "running": True, + "started_at_ms": _STARTED_AT_MS, + "started_at": _beijing_time_str(_STARTED_AT_MS), + "now_ms": now_ms, + "now": _beijing_time_str(now_ms), + }, + "redis": redis_meta, + "recommendations": reco, + "auth": { + "enabled": (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower() not in {"0", "false", "no"}, + }, + } + diff --git a/frontend/src/components/ConfigPanel.jsx b/frontend/src/components/ConfigPanel.jsx index 555dd40..10956cc 100644 --- a/frontend/src/components/ConfigPanel.jsx +++ b/frontend/src/components/ConfigPanel.jsx @@ -14,6 +14,7 @@ const ConfigPanel = ({ currentUser }) => { const [backendStatus, setBackendStatus] = useState(null) const [systemBusy, setSystemBusy] = useState(false) const [accountTradingStatus, setAccountTradingStatus] = useState(null) + const [publicStatus, setPublicStatus] = useState(null) // 多账号:当前账号(仅用于配置页提示;全局切换器在顶部导航) const [accountId, setAccountId] = useState(getCurrentAccountId()) @@ -217,6 +218,15 @@ const ConfigPanel = ({ currentUser }) => { } } + const loadPublicStatus = async () => { + try { + const res = await api.getPublicStatus() + setPublicStatus(res) + } catch (e) { + // ignore + } + } + const handleAccountTradingEnsure = async () => { setSystemBusy(true) setMessage('') @@ -354,11 +364,13 @@ const ConfigPanel = ({ currentUser }) => { loadSystemStatus() loadBackendStatus() loadAccountTradingStatus() + loadPublicStatus() const timer = setInterval(() => { loadSystemStatus() loadBackendStatus() loadAccountTradingStatus() + loadPublicStatus() }, 3000) return () => clearInterval(timer) @@ -391,6 +403,7 @@ const ConfigPanel = ({ currentUser }) => { loadSystemStatus() loadBackendStatus() loadAccountTradingStatus() + loadPublicStatus() }, [accountId]) // 顶部导航切换账号时(localStorage更新),这里做一个轻量同步 @@ -793,6 +806,22 @@ const ConfigPanel = ({ currentUser }) => { + {/* 服务状态(非管理员可见) */} +
+
+

服务状态

+
+ + 后端 {publicStatus?.backend?.running ? '在线' : '未知'} + + {publicStatus?.backend?.started_at ? 启动: {publicStatus.backend.started_at} : null} +
+
+
+ 推荐更新:{publicStatus?.recommendations?.snapshot_ok ? `最新 ${publicStatus.recommendations.generated_at_beijing || publicStatus.recommendations.generated_at || ''}(距今 ${publicStatus.recommendations.age_sec ?? '-'}s,数量 ${publicStatus.recommendations.count ?? '-'})` : '暂无/未更新(请确认 recommendations_main 进程在跑)'} +
+
+ {/* 系统控制:清缓存 / 启停 / 重启(supervisor) */} {isAdmin ? (
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 1007596..41782df 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -90,6 +90,16 @@ export const api = { return response.json(); }, + // 公共状态(非管理员也可用) + getPublicStatus: async () => { + const response = await fetch(buildUrl('/api/public/status')) + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '获取服务状态失败' })) + throw new Error(error.detail || '获取服务状态失败') + } + return response.json() + }, + // 账号管理 getAccounts: async () => { const response = await fetch(buildUrl('/api/accounts'), { headers: withAccountHeaders() });