a
This commit is contained in:
parent
6d48dc98d2
commit
4d26777845
|
|
@ -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("/")
|
||||
|
|
|
|||
183
backend/api/routes/public.py
Normal file
183
backend/api/routes/public.py
Normal 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:可用性(不暴露密码)
|
||||
- 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"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user