This commit is contained in:
薇薇安 2026-01-21 11:48:41 +08:00
parent 6d48dc98d2
commit 4d26777845
4 changed files with 230 additions and 7 deletions

View File

@ -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("/")

View File

@ -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可用性不暴露密码
- recommendationssnapshot 最新生成时间若推荐进程在跑会持续更新
"""
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"},
},
}

View File

@ -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 }) => {
</div>
</div>
{/* 服务状态(非管理员可见) */}
<div className="system-section">
<div className="system-header">
<h3>服务状态</h3>
<div className="system-status">
<span className={`system-status-badge ${publicStatus?.backend?.running ? 'running' : 'stopped'}`}>
后端 {publicStatus?.backend?.running ? '在线' : '未知'}
</span>
{publicStatus?.backend?.started_at ? <span className="system-status-meta">启动: {publicStatus.backend.started_at}</span> : null}
</div>
</div>
<div className="system-hint">
推荐更新{publicStatus?.recommendations?.snapshot_ok ? `最新 ${publicStatus.recommendations.generated_at_beijing || publicStatus.recommendations.generated_at || ''}(距今 ${publicStatus.recommendations.age_sec ?? '-'}s数量 ${publicStatus.recommendations.count ?? '-'}` : '暂无/未更新(请确认 recommendations_main 进程在跑)'}
</div>
</div>
{/* 系统控制:清缓存 / 启停 / 重启supervisor */}
{isAdmin ? (
<div className="system-section">

View File

@ -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() });