a
This commit is contained in:
parent
6d48dc98d2
commit
4d26777845
|
|
@ -3,7 +3,7 @@ FastAPI应用主入口
|
||||||
"""
|
"""
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 os
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -141,12 +141,12 @@ logger.info(f"日志级别: {os.getenv('LOG_LEVEL', 'INFO')}")
|
||||||
|
|
||||||
# 检查 redis-py 是否可用(redis-py 4.2+ 同时支持同步和异步,可替代aioredis)
|
# 检查 redis-py 是否可用(redis-py 4.2+ 同时支持同步和异步,可替代aioredis)
|
||||||
try:
|
try:
|
||||||
import redis
|
import redis # type: ignore
|
||||||
# 检查是否是 redis-py 4.2+(支持异步)
|
# 检查是否是 redis-py 4.2+(支持异步)
|
||||||
if hasattr(redis, 'asyncio'):
|
if hasattr(redis, 'asyncio'):
|
||||||
logger.info(f"✓ redis-py 已安装 (版本: {redis.__version__ if hasattr(redis, '__version__') else '未知'}),支持同步和异步客户端")
|
logger.info(f"✓ redis-py 已安装 (版本: {redis.__version__ if hasattr(redis, '__version__') else '未知'}),支持同步和异步客户端")
|
||||||
logger.info(f" - redis.Redis: 同步客户端(用于config_manager)")
|
logger.info(" - redis.Redis: 同步客户端(用于config_manager)")
|
||||||
logger.info(f" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)")
|
logger.info(" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)")
|
||||||
else:
|
else:
|
||||||
logger.warning("⚠ redis-py 版本可能过低,建议升级到 4.2+ 以获得异步支持")
|
logger.warning("⚠ redis-py 版本可能过低,建议升级到 4.2+ 以获得异步支持")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
|
|
@ -154,9 +154,9 @@ except ImportError as e:
|
||||||
logger.warning("⚠ redis-py 未安装,Redis/Valkey 缓存将不可用")
|
logger.warning("⚠ redis-py 未安装,Redis/Valkey 缓存将不可用")
|
||||||
logger.warning(f" Python 路径: {sys.executable}")
|
logger.warning(f" Python 路径: {sys.executable}")
|
||||||
logger.warning(f" 导入错误: {e}")
|
logger.warning(f" 导入错误: {e}")
|
||||||
logger.warning(f" 提示: 请运行 'pip install redis>=4.2.0' 安装 redis-py")
|
logger.warning(" 提示: 请运行 'pip install redis>=4.2.0' 安装 redis-py")
|
||||||
logger.warning(f" 注意: redis-py 4.2+ 同时支持同步和异步,无需安装 aioredis")
|
logger.warning(" 注意: redis-py 4.2+ 同时支持同步和异步,无需安装 aioredis")
|
||||||
logger.warning(f" 或者运行 'pip install -r backend/requirements.txt' 安装所有依赖")
|
logger.warning(" 或者运行 'pip install -r backend/requirements.txt' 安装所有依赖")
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Auto Trade System API",
|
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(account.router, prefix="/api/account", tags=["账户数据"])
|
||||||
app.include_router(recommendations.router, tags=["交易推荐"])
|
app.include_router(recommendations.router, tags=["交易推荐"])
|
||||||
app.include_router(system.router, tags=["系统控制"])
|
app.include_router(system.router, tags=["系统控制"])
|
||||||
|
app.include_router(public.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@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 [backendStatus, setBackendStatus] = useState(null)
|
||||||
const [systemBusy, setSystemBusy] = useState(false)
|
const [systemBusy, setSystemBusy] = useState(false)
|
||||||
const [accountTradingStatus, setAccountTradingStatus] = useState(null)
|
const [accountTradingStatus, setAccountTradingStatus] = useState(null)
|
||||||
|
const [publicStatus, setPublicStatus] = useState(null)
|
||||||
|
|
||||||
// 多账号:当前账号(仅用于配置页提示;全局切换器在顶部导航)
|
// 多账号:当前账号(仅用于配置页提示;全局切换器在顶部导航)
|
||||||
const [accountId, setAccountId] = useState(getCurrentAccountId())
|
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 () => {
|
const handleAccountTradingEnsure = async () => {
|
||||||
setSystemBusy(true)
|
setSystemBusy(true)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
|
@ -354,11 +364,13 @@ const ConfigPanel = ({ currentUser }) => {
|
||||||
loadSystemStatus()
|
loadSystemStatus()
|
||||||
loadBackendStatus()
|
loadBackendStatus()
|
||||||
loadAccountTradingStatus()
|
loadAccountTradingStatus()
|
||||||
|
loadPublicStatus()
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
loadSystemStatus()
|
loadSystemStatus()
|
||||||
loadBackendStatus()
|
loadBackendStatus()
|
||||||
loadAccountTradingStatus()
|
loadAccountTradingStatus()
|
||||||
|
loadPublicStatus()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
|
|
@ -391,6 +403,7 @@ const ConfigPanel = ({ currentUser }) => {
|
||||||
loadSystemStatus()
|
loadSystemStatus()
|
||||||
loadBackendStatus()
|
loadBackendStatus()
|
||||||
loadAccountTradingStatus()
|
loadAccountTradingStatus()
|
||||||
|
loadPublicStatus()
|
||||||
}, [accountId])
|
}, [accountId])
|
||||||
|
|
||||||
// 顶部导航切换账号时(localStorage更新),这里做一个轻量同步
|
// 顶部导航切换账号时(localStorage更新),这里做一个轻量同步
|
||||||
|
|
@ -793,6 +806,22 @@ const ConfigPanel = ({ currentUser }) => {
|
||||||
</div>
|
</div>
|
||||||
</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) */}
|
{/* 系统控制:清缓存 / 启停 / 重启(supervisor) */}
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<div className="system-section">
|
<div className="system-section">
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,16 @@ export const api = {
|
||||||
return response.json();
|
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 () => {
|
getAccounts: async () => {
|
||||||
const response = await fetch(buildUrl('/api/accounts'), { headers: withAccountHeaders() });
|
const response = await fetch(buildUrl('/api/accounts'), { headers: withAccountHeaders() });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user