This commit is contained in:
aaron 2026-04-27 14:51:18 +08:00
parent 6ac175038e
commit 13f2845201
7 changed files with 292 additions and 20 deletions

View File

@ -1,7 +1,7 @@
"""
认证API
"""
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi import APIRouter, HTTPException, Depends, Request, Response
from app.models.auth import (
SendCodeRequest, SendCodeResponse,
LoginRequest, LoginResponse,
@ -12,11 +12,54 @@ from app.services.auth_service import auth_service
from app.services.jwt_service import jwt_service
from app.services.db_service import db_service
from app.middleware.auth_middleware import get_current_user, get_client_ip
from app.middleware.console_auth import (
CONSOLE_AUTH_COOKIE,
create_console_access_token,
require_console_access,
)
from app.config import get_settings
from app.utils.logger import logger
router = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/console-login")
async def console_login(request: Request, response: Response):
try:
body = await request.json()
except Exception:
body = {}
password = str(body.get("password") or "")
settings = get_settings()
if not password or password != settings.console_access_password:
raise HTTPException(status_code=403, detail="访问密码错误")
token = create_console_access_token()
max_age = max(1, int(settings.console_access_expire_days or 30)) * 24 * 60 * 60
response.set_cookie(
key=CONSOLE_AUTH_COOKIE,
value=token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=False,
path="/",
)
return {"success": True, "message": "总控台登录成功"}
@router.post("/console-logout")
async def console_logout(response: Response):
response.delete_cookie(CONSOLE_AUTH_COOKIE, path="/")
return {"success": True, "message": "已退出总控台"}
@router.get("/console-session")
async def get_console_session(_: dict = Depends(require_console_access)):
return {"success": True, "authenticated": True}
@router.post("/send-code", response_model=SendCodeResponse)
async def send_verification_code(
request_data: SendCodeRequest,

View File

@ -1,7 +1,7 @@
"""
交易 API
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import Optional
from datetime import datetime
from pydantic import BaseModel
@ -13,6 +13,7 @@ from app.services.db_service import db_service
from app.services.runtime_status_service import get_runtime_status
from app.utils.logger import logger
from app.crypto_agent.crypto_agent import get_crypto_agent
from app.middleware.console_auth import require_console_access
router = APIRouter(prefix="/api/trading", tags=["交易"])
@ -249,7 +250,7 @@ async def get_platform_halts():
@router.get("/execution-controls")
async def get_execution_controls():
async def get_execution_controls(_: dict = Depends(require_console_access)):
"""获取目标级自动交易开关状态"""
try:
agent = get_crypto_agent()
@ -263,7 +264,7 @@ async def get_execution_controls():
@router.post("/execution-controls")
async def set_execution_controls(request: ExecutionControlRequest):
async def set_execution_controls(request: ExecutionControlRequest, _: dict = Depends(require_console_access)):
"""设置目标级自动交易开关"""
try:
agent = get_crypto_agent()
@ -286,7 +287,7 @@ async def set_execution_controls(request: ExecutionControlRequest):
@router.post("/platform-halts/resume")
async def resume_platform(request: ResumePlatformRequest):
async def resume_platform(request: ResumePlatformRequest, _: dict = Depends(require_console_access)):
"""手动恢复指定平台执行"""
try:
agent = get_crypto_agent()

View File

@ -2,10 +2,11 @@
系统状态 API
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from typing import Dict, Any
import numpy as np
from app.config import get_settings
from app.middleware.console_auth import require_console_access
from app.utils.logger import logger
from app.utils.system_status import get_system_monitor
from app.crypto_agent.crypto_agent import get_crypto_agent
@ -339,7 +340,7 @@ async def get_agent_status(agent_id: str):
@router.get("/console", response_model=Dict[str, Any])
async def get_console_snapshot():
async def get_console_snapshot(_: dict = Depends(require_console_access)):
"""
获取总控台快照

View File

@ -64,6 +64,8 @@ class Settings(BaseSettings):
# 安全配置
secret_key: str = "change-this-secret-key-in-production"
rate_limit: str = "100/minute"
console_access_password: str = "223388" # 总控台访问密码
console_access_expire_days: int = 30 # 总控台访问会话有效期
# JWT配置
jwt_algorithm: str = "HS256"

View File

@ -2447,20 +2447,24 @@ class CryptoAgent:
if setup_type in {'trend_continuation_pullback', 'deep_pullback_continuation', 'breakout_pullback'}:
if entry_type != 'limit':
return False, f"{setup_type} 应使用 limit 等待回踩/反抽"
if location_tag == 'far_from_trade_zone':
return False, f"{setup_type} 当前远离有效回踩交易区"
if pullback_quality != 'healthy_pullback':
return False, f"{setup_type} 缺少健康缩量回调证据"
if location_tag in {'far_from_trade_zone', 'middle_of_range'}:
return False, f"{setup_type} 当前不在有效回踩交易区"
return True, f"{setup_type} 缺少健康缩量回调证据,降级执行"
if location_tag == 'middle_of_range':
return True, f"{setup_type} 当前位于区间中部,降级执行"
if setup_type == 'range_reversal':
if location_tag == 'far_from_trade_zone':
return False, "区间反转 setup 远离关键交易区"
if location_tag not in {'near_range_support', 'near_range_resistance'}:
return False, "区间反转 setup 不在区间边界"
return True, "区间反转 setup 不在区间边界,降级执行"
if rejection_signal not in {'bullish_rejection', 'bearish_rejection'} and entry_type == 'market':
return False, "区间反转现价执行缺少明确拒绝信号"
if setup_type == 'trend_reversal':
if rejection_signal not in {'bullish_rejection', 'bearish_rejection'}:
return False, "趋势反转 setup 缺少拒绝/结构切换证据"
return True, "趋势反转 setup 缺少明确拒绝信号,降级执行"
if exhaustion_risk in {'upside_climax', 'downside_climax'} and setup_type != 'trend_reversal':
return False, "当前量价处于高潮风险,非反转 setup 不执行"
@ -3995,6 +3999,9 @@ class CryptoAgent:
setup_passed, setup_reason = self._check_setup_execution_constraints(signal)
if not setup_passed:
return False, f"setup 过滤: {setup_reason}"
if setup_reason != "setup 约束通过":
signal["_setup_execution_warning"] = setup_reason
logger.info(f"[{platform_name}] ⚠️ setup 降级执行: {setup_reason}")
current_leverage = account.get('current_total_leverage', 0)
max_leverage = account.get('max_total_leverage', 10)

View File

@ -0,0 +1,39 @@
from datetime import datetime, timedelta
from typing import Dict
from fastapi import Cookie, HTTPException
from jose import JWTError, jwt
from app.config import get_settings
CONSOLE_AUTH_COOKIE = "console_access_token"
def create_console_access_token() -> str:
settings = get_settings()
expire = datetime.utcnow() + timedelta(days=max(1, int(settings.console_access_expire_days or 30)))
payload = {
"scope": "console_access",
"exp": expire,
"iat": datetime.utcnow(),
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
def verify_console_access_token(token: str) -> Dict:
settings = get_settings()
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise HTTPException(status_code=401, detail="总控台访问已失效,请重新登录") from exc
if payload.get("scope") != "console_access":
raise HTTPException(status_code=401, detail="总控台访问凭证无效")
return payload
def require_console_access(console_access_token: str | None = Cookie(default=None, alias=CONSOLE_AUTH_COOKIE)) -> Dict:
if not console_access_token:
raise HTTPException(status_code=401, detail="请先登录总控台")
return verify_console_access_token(console_access_token)

View File

@ -52,6 +52,70 @@
mask-image: linear-gradient(180deg, rgba(0,0,0,0.45), rgba(0,0,0,0.9));
}
.auth-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(3, 8, 14, 0.82);
backdrop-filter: blur(18px);
}
.auth-overlay.hidden {
display: none;
}
.auth-card {
width: min(420px, 100%);
padding: 28px;
border-radius: 24px;
background: rgba(9, 18, 28, 0.96);
border: 1px solid rgba(128, 169, 202, 0.24);
box-shadow: var(--shadow);
}
.auth-card h2 {
margin: 0 0 10px;
font-size: 28px;
}
.auth-card p {
margin: 0 0 18px;
color: var(--muted);
line-height: 1.7;
}
.auth-field {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
color: var(--text);
font-size: 15px;
outline: none;
}
.auth-field:focus {
border-color: rgba(126, 200, 255, 0.36);
}
.auth-actions {
display: flex;
gap: 12px;
margin-top: 14px;
}
.auth-error {
min-height: 20px;
margin-top: 12px;
color: var(--danger);
font-size: 13px;
}
.console-shell {
width: min(1600px, calc(100vw - 32px));
margin: 0 auto;
@ -2466,6 +2530,19 @@
</style>
</head>
<body>
<div class="auth-overlay" id="consoleAuthOverlay">
<div class="auth-card">
<div class="eyebrow">Console Access</div>
<h2>输入访问密码</h2>
<p>总控台包含账户权益、持仓、挂单和执行控制,需要先完成访问验证。</p>
<input id="consolePasswordInput" class="auth-field" type="password" placeholder="请输入总控台访问密码">
<div class="auth-actions">
<button class="action-btn action-primary" id="consoleLoginBtn">进入总控台</button>
</div>
<div class="auth-error" id="consoleAuthError"></div>
</div>
</div>
<div class="console-shell">
<section class="hero">
<div class="hero-card hero-main">
@ -2691,6 +2768,7 @@
let cachedExecutionEvents = [];
let cachedConsoleData = null;
let revealSensitiveData = false;
let consoleAuthenticated = false;
const SENSITIVE_VISIBILITY_KEY = 'console_sensitive_visible_v2';
const HUB_PREFERENCE_KEY = 'console_hub_active_v1';
@ -2712,10 +2790,24 @@
return Number.isFinite(num) ? `$${formatNumber(num, 2)}` : '-';
}
function parseConsoleDate(value) {
if (!value) return null;
if (value instanceof Date) return value;
const text = String(value).trim();
if (!text) return null;
const normalized = /([zZ]|[+-]\d{2}:\d{2})$/.test(text) ? text : `${text}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatTime(value) {
if (!value) return '-';
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
const date = parseConsoleDate(value);
if (!date) return String(value);
return date.toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' });
} catch {
return String(value);
}
@ -2723,7 +2815,8 @@
function relativeTime(value) {
if (!value) return '-';
const target = new Date(value);
const target = parseConsoleDate(value);
if (!target) return String(value);
if (Number.isNaN(target.getTime())) return String(value);
const diff = Date.now() - target.getTime();
const sec = Math.max(0, Math.floor(diff / 1000));
@ -2772,6 +2865,64 @@
`;
}
function showConsoleAuthOverlay(message = '') {
document.getElementById('consoleAuthOverlay').classList.remove('hidden');
document.getElementById('consoleAuthError').textContent = message;
}
function hideConsoleAuthOverlay() {
document.getElementById('consoleAuthOverlay').classList.add('hidden');
document.getElementById('consoleAuthError').textContent = '';
}
async function ensureConsoleSession() {
const response = await fetch('/api/auth/console-session');
if (!response.ok) {
consoleAuthenticated = false;
showConsoleAuthOverlay(response.status === 401 ? '请输入访问密码后继续。' : '总控台认证失败');
return false;
}
consoleAuthenticated = true;
hideConsoleAuthOverlay();
return true;
}
async function loginConsole() {
const input = document.getElementById('consolePasswordInput');
const password = input.value.trim();
if (!password) {
showConsoleAuthOverlay('请输入访问密码');
return;
}
const button = document.getElementById('consoleLoginBtn');
button.disabled = true;
button.textContent = '验证中...';
try {
const response = await fetch('/api/auth/console-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.detail || result.message || '访问密码错误');
}
input.value = '';
consoleAuthenticated = true;
hideConsoleAuthOverlay();
loadConsole();
} catch (error) {
showConsoleAuthOverlay(error.message || '登录失败');
} finally {
button.disabled = false;
button.textContent = '进入总控台';
}
}
function normalizeSeverity(severity) {
const value = String(severity || '').toLowerCase();
if (['danger', 'error', 'critical'].includes(value)) return 'danger';
@ -2783,7 +2934,8 @@
const cutoff = Date.now() - hours * 3600 * 1000;
return (events || []).filter((event) => {
const rawTime = event?.timestamp || event?.created_at || event?.opened_at;
const time = rawTime ? new Date(rawTime).getTime() : 0;
const parsed = parseConsoleDate(rawTime);
const time = parsed ? parsed.getTime() : 0;
if (!time || Number.isNaN(time) || time < cutoff) return false;
return typeof matcher === 'function' ? matcher(event) : true;
}).length;
@ -2804,7 +2956,7 @@
const protectionCoverage = positions.length > 0 ? (completeProtectionCount / positions.length) * 100 : 100;
const conversionRate = recentSignal24h > 0 ? (success24h / recentSignal24h) * 100 : 0;
const staleEntryOrders = entryOrders.filter((order) => {
const created = order.created_at ? new Date(order.created_at).getTime() : 0;
const created = order.created_at ? (parseConsoleDate(order.created_at)?.getTime() || 0) : 0;
return created && !Number.isNaN(created) && (Date.now() - created) > (30 * 60 * 1000);
}).length;
@ -2937,17 +3089,21 @@
}
function findMatchingEvent(signal, laneKeyword, events, platform) {
const signalTime = signal?.created_at ? new Date(signal.created_at).getTime() : 0;
const signalTime = signal?.created_at ? (parseConsoleDate(signal.created_at)?.getTime() || 0) : 0;
const symbol = signal?.symbol;
const candidates = (events || []).filter((event) => {
if (event.symbol !== symbol) return false;
if (!eventPlatformMatches(event, platform)) return false;
if (laneKeyword && event.signal_timeframe_text && !String(event.signal_timeframe_text).includes(laneKeyword)) return false;
const eventTime = event?.timestamp ? new Date(event.timestamp).getTime() : 0;
const eventTime = event?.timestamp ? (parseConsoleDate(event.timestamp)?.getTime() || 0) : 0;
if (!signalTime || !eventTime || Number.isNaN(signalTime) || Number.isNaN(eventTime)) return true;
return Math.abs(eventTime - signalTime) <= 12 * 3600 * 1000;
});
return candidates.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0))[0] || null;
return candidates.sort((a, b) => {
const bt = parseConsoleDate(b.timestamp)?.getTime() || 0;
const at = parseConsoleDate(a.timestamp)?.getTime() || 0;
return bt - at;
})[0] || null;
}
function findMatchingPosition(signal, platform, positions) {
@ -3948,6 +4104,10 @@
const result = await response.json();
if (!response.ok || !result.success) {
if (response.status === 401) {
consoleAuthenticated = false;
showConsoleAuthOverlay('访问会话已失效,请重新输入密码');
}
throw new Error(result.detail || result.message || '恢复失败');
}
@ -3978,6 +4138,10 @@
const result = await response.json();
if (!response.ok || !result.success) {
if (response.status === 401) {
consoleAuthenticated = false;
showConsoleAuthOverlay('访问会话已失效,请重新输入密码');
}
throw new Error(result.detail || result.message || '更新失败');
}
@ -3992,8 +4156,17 @@
async function loadConsole() {
try {
if (!consoleAuthenticated) {
const ok = await ensureConsoleSession();
if (!ok) return;
}
setFeedback('');
const response = await fetch('/api/system/console');
if (response.status === 401) {
consoleAuthenticated = false;
showConsoleAuthOverlay('访问会话已失效,请重新输入密码');
return;
}
const result = await response.json();
if (result.status !== 'success') {
@ -4045,12 +4218,18 @@
applyAutoRefreshState();
});
document.getElementById('toggleSensitiveBtn').addEventListener('click', toggleSensitiveData);
document.getElementById('consoleLoginBtn').addEventListener('click', loginConsole);
document.getElementById('consolePasswordInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loginConsole();
});
loadSensitivePreference();
initTabs();
initHubNavigation();
applyAutoRefreshState();
loadConsole();
ensureConsoleSession().then((ok) => {
if (ok) loadConsole();
});
</script>
</body>
</html>