You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1566 lines
68 KiB

"""主应用入口 - 对应Go的cmd/server/main.go"""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.api import router, admin_router
from app.websocket import WebSocketServer
from app.core.config import get_config, get_settings
from app.core.logger import info, error, setup_logging
from app.core.metrics import MetricsMiddleware, get_metrics_response, set_app_info
from app.core.rate_limiter import RateLimitMiddleware, RateLimitConfig
from app.repositories.database import init_db
# 获取配置
config = get_config()
settings = get_settings()
# 设置日志
setup_logging()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时执行
info("Starting Market Data Service...")
# 初始化数据库
try:
init_db()
info("Database initialized")
except Exception as e:
error(f"Database initialization failed: {e}")
yield
# 关闭时执行
info("Shutting down Market Data Service...")
# 创建FastAPI应用
app = FastAPI(
title="统一行情数据服务",
description="提供股票和期货的标准化行情数据查询服务",
version="1.0.0",
lifespan=lifespan
)
# 添加CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 添加Prometheus指标中间件
app.add_middleware(MetricsMiddleware)
# 添加限流中间件默认每分钟60请求滑动窗口算法
app.add_middleware(
RateLimitMiddleware,
config=RateLimitConfig(
requests_per_minute=120, # 每分钟120请求
burst_size=20, # 突发20请求
strategy="sliding_window" # 使用滑动窗口算法
)
)
# 注册API路由
app.include_router(router, prefix="/v1")
app.include_router(admin_router, prefix="/v1")
# WebSocket服务器
ws_server = WebSocketServer()
@app.websocket("/v1/stream")
async def websocket_endpoint(websocket):
"""WebSocket端点"""
import uuid
client_id = str(uuid.uuid4())
await ws_server.handle(websocket, client_id)
@app.get("/metrics")
async def metrics():
"""Prometheus指标端点"""
return get_metrics_response()
# 管理后台页面HTML完整版
ADMIN_HTML = '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>行情数据服务 - 管理后台</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
background: #f0f2f5;
color: #333;
}
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 200px;
background: #001529;
color: #fff;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.logo {
padding: 16px;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.nav-menu { padding: 16px 0; }
.nav-item {
padding: 12px 24px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
.nav-item:hover { background: rgba(255,255,255,0.05); }
.nav-item.active { background: #1890ff; }
.main-content {
flex: 1;
margin-left: 200px;
padding: 24px;
}
.header {
background: #fff;
padding: 16px 24px;
margin: -24px -24px 24px -24px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.page-title { font-size: 20px; font-weight: 500; }
.card {
background: #fff;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.card-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #1890ff;
}
.stat-label { color: #666; margin-top: 4px; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary { background: #1890ff; color: #fff; }
.btn-primary:hover { background: #40a9ff; }
.btn-success { background: #52c41a; color: #fff; }
.btn-success:hover { background: #73d13d; }
.btn-danger { background: #ff4d4f; color: #fff; }
.btn-danger:hover { background: #ff7875; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.hidden { display: none; }
.page { display: none; }
.page.active { display: block; }
/* 测试页面样式 */
.test-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 8px;
}
.test-tab {
padding: 8px 16px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
}
.test-tab:hover { background: #f0f0f0; }
.test-tab.active { background: #1890ff; color: #fff; }
.test-panel { display: none; }
.test-panel.active { display: block; }
.test-category {
margin-bottom: 24px;
}
.test-category-title {
font-size: 14px;
font-weight: 600;
color: #1890ff;
margin-bottom: 12px;
padding: 8px 12px;
background: #e6f7ff;
border-radius: 4px;
}
.test-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 4px;
margin-bottom: 8px;
transition: all 0.3s;
}
.test-item:hover {
border-color: #1890ff;
background: #f6ffed;
}
.test-item-info { flex: 1; }
.test-item-name {
font-weight: 500;
margin-bottom: 4px;
}
.test-item-desc {
font-size: 12px;
color: #666;
}
.test-item-meta {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.test-item-actions {
display: flex;
gap: 8px;
align-items: center;
}
.test-result {
margin-top: 8px;
padding: 12px;
border-radius: 4px;
font-size: 12px;
display: none;
}
.test-result.show { display: block; }
.test-result.success {
background: #f6ffed;
border: 1px solid #b7eb8f;
}
.test-result.error {
background: #fff2f0;
border: 1px solid #ffccc7;
}
.test-result-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 500;
}
.test-result-body {
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
background: rgba(0,0,0,0.03);
padding: 8px;
border-radius: 4px;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.status-success { background: #52c41a; color: #fff; }
.status-error { background: #ff4d4f; color: #fff; }
.status-running { background: #1890ff; color: #fff; }
.batch-actions {
margin-bottom: 16px;
padding: 12px;
background: #fafafa;
border-radius: 4px;
}
.loading-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #f3f3f3;
border-top: 2px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(0deg); } }
</style>
<script>
// 基础页面切换函数必须在HTML调用之前定义
function showPage(pageName) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(pageName).classList.add('active');
if (event && event.target) {
event.target.classList.add('active');
}
}
function switchTestTab(tab) {
document.querySelectorAll('.test-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.test-panel').forEach(p => p.classList.remove('active'));
if (event && event.target) {
event.target.classList.add('active');
}
document.getElementById(tab + '-test-panel').classList.add('active');
}
</script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="logo">📊 行情数据服务</div>
<nav class="nav-menu">
<div class="nav-item active" onclick="showPage('dashboard')">
<span>📈</span> 系统概览
</div>
<div class="nav-item" onclick="showPage('config')">
<span></span> 配置管理
</div>
<div class="nav-item" onclick="showPage('adapters')">
<span>🔌</span> 数据源适配
</div>
<div class="nav-item" onclick="showPage('tests')">
<span>🧪</span> 接口测试
</div>
</nav>
</aside>
<main class="main-content">
<div class="header">
<h1 class="page-title">系统概览</h1>
<div>
<button class="btn btn-success" onclick="reloadConfig()">🔄 热加载配置</button>
<button class="btn btn-danger" onclick="restartService()">🔁 重启服务</button>
</div>
</div>
<!-- 系统概览页面 -->
<div id="dashboard" class="page active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-status">运行中</div>
<div class="stat-label">运行状态</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-uptime">-</div>
<div class="stat-label">运行时长</div>
</div>
<div class="stat-card">
<div class="stat-value">1.0.0</div>
<div class="stat-label">系统版本</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-threads">-</div>
<div class="stat-label">线程数量</div>
</div>
</div>
<div class="card">
<div class="card-title">API文档</div>
<p>访问 <a href="/docs">/docs</a> 查看Swagger API文档</p>
<p>访问 <a href="/redoc">/redoc</a> 查看ReDoc API文档</p>
</div>
</div>
<!-- 配置管理页面 -->
<div id="config" class="page">
<div class="card">
<div class="card-title">系统配置</div>
<div id="config-list">加载中...</div>
</div>
</div>
<!-- 数据源适配页面 -->
<div id="adapters" class="page">
<div class="card">
<div class="card-title">数据源适配器管理</div>
<div class="batch-actions">
<button class="btn btn-primary btn-sm" onclick="loadAdapterList()">🔄 刷新列表</button>
</div>
<div id="adapter-list">加载中...</div>
</div>
<div class="card">
<div class="card-title">当前数据源状态</div>
<div id="source-status">加载中...</div>
</div>
<!-- 适配器配置编辑模态框 -->
<div id="adapter-config-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:1000;justify-content:center;align-items:center;">
<div style="background:#fff;border-radius:8px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;">配置适配器: <span id="modal-adapter-name"></span></h3>
<button onclick="closeAdapterModal()" style="border:none;background:none;font-size:20px;cursor:pointer;">&times;</button>
</div>
<div id="adapter-config-form">
<div style="margin-bottom:16px;">
<label style="display:block;margin-bottom:4px;font-weight:500;">状态</label>
<select id="modal-adapter-enabled" style="width:100%;padding:8px;border:1px solid #d9d9d9;border-radius:4px;">
<option value="true">启用</option>
<option value="false">禁用</option>
</select>
</div>
<div id="modal-config-fields"></div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:24px;">
<button class="btn" onclick="closeAdapterModal()">取消</button>
<button class="btn btn-primary" onclick="saveAdapterConfig()">保存</button>
</div>
</div>
</div>
</div>
<!-- 接口测试页面 -->
<div id="tests" class="page">
<div class="card">
<div class="card-title">接口测试</div>
<div class="test-tabs">
<div class="test-tab active" onclick="switchTestTab('quick')">快速测试</div>
<div class="test-tab" onclick="switchTestTab('api')">API 测试套件</div>
<div class="test-tab" onclick="switchTestTab('internal')">对内接口测试</div>
<div class="test-tab" onclick="switchTestTab('ws')">WebSocket 测试</div>
<div class="test-tab" onclick="switchTestTab('history')">测试历史</div>
</div>
<!-- 快速测试面板 -->
<div id="quick-test-panel" class="test-panel active">
<div class="batch-actions">
<button class="btn btn-primary btn-sm" onclick="runAllQuickTests()"> 运行全部快速测试</button>
<button class="btn btn-sm" onclick="clearAllQuickResults()">🗑 清除结果</button>
</div>
<div class="test-category">
<div class="test-category-title">股票接口</div>
<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">查询股票K线</div>
<div class="test-item-desc">GET /v1/stock/klines/{symbol}</div>
</div>
<div class="test-item-actions">
<input type="text" id="test-stock-symbol" placeholder="000001.SZ" value="000001.SZ" style="padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;width:100px;margin-right:8px;font-size:12px;">
<select id="test-stock-freq" style="padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;margin-right:8px;font-size:12px;">
<option value="1d">日线</option>
<option value="1m">1分钟</option>
<option value="5m">5分钟</option>
</select>
<button class="btn btn-primary btn-sm" onclick="quickTestStockKlines()">测试</button>
</div>
</div>
<div class="test-result" id="test-result-stock-klines"></div>
<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">查询股票列表</div>
<div class="test-item-desc">GET /v1/stock/symbols</div>
</div>
<div class="test-item-actions">
<button class="btn btn-primary btn-sm" onclick="quickTestStockSymbols()">测试</button>
</div>
</div>
<div class="test-result" id="test-result-stock-symbols"></div>
</div>
<div class="test-category">
<div class="test-category-title">期货接口</div>
<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">查询期货K线</div>
<div class="test-item-desc">GET /v1/futures/klines/{symbol}</div>
</div>
<div class="test-item-actions">
<input type="text" id="test-futures-symbol" placeholder="CU2504.SHFE" value="CU2504.SHFE" style="padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;width:120px;margin-right:8px;font-size:12px;">
<select id="test-futures-freq" style="padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;margin-right:8px;font-size:12px;">
<option value="1d">日线</option>
<option value="1m">1分钟</option>
</select>
<button class="btn btn-primary btn-sm" onclick="quickTestFuturesKlines()">测试</button>
</div>
</div>
<div class="test-result" id="test-result-futures-klines"></div>
<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">查询期货列表</div>
<div class="test-item-desc">GET /v1/futures/symbols</div>
</div>
<div class="test-item-actions">
<button class="btn btn-primary btn-sm" onclick="quickTestFuturesSymbols()">测试</button>
</div>
</div>
<div class="test-result" id="test-result-futures-symbols"></div>
</div>
<div class="test-category">
<div class="test-category-title">管理接口</div>
<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">健康检查</div>
<div class="test-item-desc">GET /v1/admin/health</div>
</div>
<div class="test-item-actions">
<button class="btn btn-primary btn-sm" onclick="quickTestHealth()">测试</button>
</div>
</div>
<div class="test-result" id="test-result-health"></div>
<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">数据源状态</div>
<div class="test-item-desc">GET /v1/admin/source/status</div>
</div>
<div class="test-item-actions">
<button class="btn btn-primary btn-sm" onclick="quickTestSourceStatus()">测试</button>
</div>
</div>
<div class="test-result" id="test-result-source-status"></div>
</div>
</div>
<!-- API 测试面板 -->
<div id="api-test-panel" class="test-panel">
<div class="batch-actions">
<button class="btn btn-primary btn-sm" onclick="runAllAPITests()"> 运行全部 API 测试</button>
<button class="btn btn-sm" onclick="clearAllAPIResults()">🗑 清除结果</button>
</div>
<div id="api-test-list">加载中...</div>
</div>
<!-- 对内接口测试面板 -->
<div id="internal-test-panel" class="test-panel">
<div class="batch-actions">
<button class="btn btn-primary btn-sm" onclick="runAllInternalTests()"> 运行全部对内接口测试</button>
<button class="btn btn-sm" onclick="clearAllInternalResults()">🗑 清除结果</button>
</div>
<div id="internal-test-list">加载中...</div>
</div>
<!-- WebSocket 测试面板 -->
<div id="ws-test-panel" class="test-panel">
<div class="batch-actions">
<button class="btn btn-primary btn-sm" onclick="runAllWSTests()"> 运行全部 WebSocket 测试</button>
<button class="btn btn-sm" onclick="clearAllWSResults()">🗑 清除结果</button>
</div>
<div id="ws-test-list">加载中...</div>
</div>
<!-- 测试历史面板 -->
<div id="history-test-panel" class="test-panel">
<div class="batch-actions">
<button class="btn btn-sm" onclick="loadTestHistory()">🔄 刷新</button>
<button class="btn btn-danger btn-sm" onclick="clearTestHistory()">🗑 清空历史</button>
</div>
<div id="test-history-list">加载中...</div>
</div>
</div>
</div>
</main>
</div>
<script>
// API Key 存储
let apiKey = localStorage.getItem('apiKey') || '';
// 页面切换增强在底部script中补充数据加载逻辑
const _originalShowPage = showPage;
showPage = function(pageName) {
_originalShowPage(pageName);
if (pageName === 'tests') {
loadAPITestList();
loadInternalTestList();
loadWSTestList();
} else if (pageName === 'config') {
loadConfigList();
} else if (pageName === 'adapters') {
loadAdapterList();
}
};
const _originalSwitchTestTab = switchTestTab;
switchTestTab = function(tab) {
_originalSwitchTestTab(tab);
if (tab === 'history') loadTestHistory();
if (tab === 'internal') loadInternalTestList();
};
// ============ API 测试 ============
let apiTestCases = [];
async function loadAPITestList() {
try {
const response = await fetch('/v1/admin/tests/api', {
headers: { 'X-Admin-Token': apiKey }
});
const data = await response.json();
if (data.code === 0) {
apiTestCases = data.data.categories || [];
renderAPITestList(data.data.base_url);
}
} catch (e) {
document.getElementById('api-test-list').innerHTML = '加载失败: ' + e.message;
}
}
function renderAPITestList(baseUrl) {
let html = '';
apiTestCases.forEach(cat => {
html += `<div class="test-category">
<div class="test-category-title">${cat.name}</div>`;
cat.items.forEach(item => {
html += `<div class="test-item" id="api-test-${item.id}">
<div class="test-item-info">
<div class="test-item-name">
<span class="status-badge" style="background:#f0f0f0;color:#666;margin-right:8px;">${item.method}</span>
${item.name}
</div>
<div class="test-item-desc">${item.description}</div>
<div class="test-item-meta">${item.path}</div>
</div>
<div class="test-item-actions">
<button class="btn btn-primary btn-sm" onclick="runAPITest('${item.id}')" id="btn-api-${item.id}">测试</button>
</div>
</div>
<div class="test-result" id="api-result-${item.id}"></div>`;
});
html += '</div>';
});
document.getElementById('api-test-list').innerHTML = html;
}
async function runAPITest(testId) {
const btn = document.getElementById(`btn-api-${testId}`);
const resultDiv = document.getElementById(`api-result-${testId}`);
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span>';
resultDiv.className = 'test-result';
resultDiv.classList.add('show');
resultDiv.innerHTML = '运行中...';
try {
const response = await fetch('/v1/admin/tests/api/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': apiKey
},
body: JSON.stringify({ id: testId })
});
const data = await response.json();
if (data.code === 0 && data.data) {
const result = data.data;
const isSuccess = result.success;
resultDiv.className = 'test-result show ' + (isSuccess ? 'success' : 'error');
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${isSuccess ? '✅ 测试通过' : '❌ 测试失败'}</span>
<span>${result.latency}ms | HTTP ${result.status_code}</span>
</div>
<div style="margin-bottom:8px;">
<strong>请求:</strong> ${result.request.method} ${result.request.url}
</div>
${result.request.body ? `<div style="margin-bottom:8px;"><strong>请求体:</strong> <pre>${JSON.stringify(result.request.body, null, 2)}</pre></div>` : ''}
<div>
<strong>响应:</strong>
<div class="test-result-body">${typeof result.response === 'object' ? JSON.stringify(result.response, null, 2) : result.response}</div>
</div>
${result.error ? `<div style="color:#ff4d4f;margin-top:8px;"><strong>错误:</strong> ${result.error}</div>` : ''}
`;
btn.innerHTML = isSuccess ? '✓ 通过' : '✗ 失败';
btn.className = isSuccess ? 'btn btn-success btn-sm' : 'btn btn-danger btn-sm';
} else {
throw new Error(data.message || '测试执行失败');
}
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 执行错误</span></div><div>${e.message}</div>`;
btn.innerHTML = '✗ 失败';
btn.className = 'btn btn-danger btn-sm';
}
btn.disabled = false;
}
async function runAllAPITests() {
const allIds = [];
apiTestCases.forEach(cat => cat.items.forEach(item => allIds.push(item.id)));
for (const id of allIds) {
await runAPITest(id);
await new Promise(r => setTimeout(r, 500)); // 间隔500ms避免请求过快
}
}
function clearAllAPIResults() {
document.querySelectorAll('[id^="api-result-"]').forEach(el => {
el.className = 'test-result';
el.innerHTML = '';
});
document.querySelectorAll('[id^="btn-api-"]').forEach(el => {
el.innerHTML = '测试';
el.className = 'btn btn-primary btn-sm';
el.disabled = false;
});
}
// ============ 对内接口测试 ============
let internalTestCases = [];
async function loadInternalTestList() {
try {
const response = await fetch('/v1/admin/tests/internal', {
headers: { 'X-Admin-Token': apiKey }
});
const data = await response.json();
if (data.code === 0) {
internalTestCases = data.data.categories || [];
renderInternalTestList();
}
} catch (e) {
document.getElementById('internal-test-list').innerHTML = '加载失败: ' + e.message;
}
}
function renderInternalTestList() {
let html = '';
internalTestCases.forEach(cat => {
html += `<div class="test-category">
<div class="test-category-title">${cat.name}</div>`;
cat.items.forEach(item => {
html += `<div class="test-item" id="internal-test-${item.id}">
<div class="test-item-info">
<div class="test-item-name">
<span class="status-badge" style="background:#722ed1;color:#fff;margin-right:8px;">SDK</span>
${item.name}
</div>
<div class="test-item-desc">${item.description}</div>
<div class="test-item-meta">${item.method}</div>
</div>
<div class="test-item-actions">
<button class="btn btn-primary btn-sm" onclick="runInternalTest('${item.id}')" id="btn-internal-${item.id}">测试</button>
</div>
</div>
<div class="test-result" id="internal-result-${item.id}"></div>`;
});
html += '</div>';
});
document.getElementById('internal-test-list').innerHTML = html;
}
async function runInternalTest(testId) {
const btn = document.getElementById(`btn-internal-${testId}`);
const resultDiv = document.getElementById(`internal-result-${testId}`);
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span>';
resultDiv.className = 'test-result';
resultDiv.classList.add('show');
resultDiv.innerHTML = '运行中...';
try {
const response = await fetch('/v1/admin/tests/internal/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': apiKey
},
body: JSON.stringify({ id: testId })
});
const data = await response.json();
if (data.code === 0 && data.data) {
const result = data.data;
const isSuccess = result.success;
resultDiv.className = 'test-result show ' + (isSuccess ? 'success' : 'error');
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${isSuccess ? '✅ 测试通过' : '❌ 测试失败'}</span>
<span>${result.latency}ms</span>
</div>
<div style="margin-bottom:8px;">
<strong>接口:</strong> ${result.interface}
</div>
<div style="margin-bottom:8px;">
<strong>参数:</strong> <pre>${JSON.stringify(result.params, null, 2)}</pre>
</div>
<div>
<strong>响应:</strong>
<div class="test-result-body">${typeof result.response === 'object' ? JSON.stringify(result.response, null, 2) : result.response}</div>
</div>
${result.error ? `<div style="color:#ff4d4f;margin-top:8px;"><strong>错误:</strong> ${result.error}</div>` : ''}
`;
btn.innerHTML = isSuccess ? '✓ 通过' : '✗ 失败';
btn.className = isSuccess ? 'btn btn-success btn-sm' : 'btn btn-danger btn-sm';
} else {
throw new Error(data.message || '测试执行失败');
}
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 执行错误</span></div><div>${e.message}</div>`;
btn.innerHTML = '✗ 失败';
btn.className = 'btn btn-danger btn-sm';
}
btn.disabled = false;
}
async function runAllInternalTests() {
const allIds = [];
internalTestCases.forEach(cat => cat.items.forEach(item => allIds.push(item.id)));
for (const id of allIds) {
await runInternalTest(id);
await new Promise(r => setTimeout(r, 300));
}
}
function clearAllInternalResults() {
document.querySelectorAll('[id^="internal-result-"]').forEach(el => {
el.className = 'test-result';
el.innerHTML = '';
});
document.querySelectorAll('[id^="btn-internal-"]').forEach(el => {
el.innerHTML = '测试';
el.className = 'btn btn-primary btn-sm';
el.disabled = false;
});
}
// ============ WebSocket 测试 ============
let wsTestCases = [];
async function loadWSTestList() {
try {
const response = await fetch('/v1/admin/tests/ws', {
headers: { 'X-Admin-Token': apiKey }
});
const data = await response.json();
if (data.code === 0) {
wsTestCases = data.data.cases || [];
renderWSTestList(data.data.ws_url);
}
} catch (e) {
document.getElementById('ws-test-list').innerHTML = '加载失败: ' + e.message;
}
}
function renderWSTestList(wsUrl) {
let html = `<div class="test-category">
<div class="test-category-title">WebSocket 连接测试 (${wsUrl})</div>`;
wsTestCases.forEach(item => {
const symbolsStr = item.symbols ? item.symbols.join(', ') : '';
html += `<div class="test-item" id="ws-test-${item.id}">
<div class="test-item-info">
<div class="test-item-name">${item.name}</div>
<div class="test-item-desc">${item.description}</div>
<div class="test-item-meta">动作: ${item.action} | 标的: ${symbolsStr}</div>
</div>
<div class="test-item-actions">
<button class="btn btn-primary btn-sm" onclick="runWSTest('${item.id}')" id="btn-ws-${item.id}">测试</button>
</div>
</div>
<div class="test-result" id="ws-result-${item.id}"></div>`;
});
html += '</div>';
document.getElementById('ws-test-list').innerHTML = html;
}
async function runWSTest(testId) {
const btn = document.getElementById(`btn-ws-${testId}`);
const resultDiv = document.getElementById(`ws-result-${testId}`);
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span>';
resultDiv.className = 'test-result show';
resultDiv.innerHTML = '连接中...';
try {
const response = await fetch('/v1/admin/tests/ws/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': apiKey
},
body: JSON.stringify({ id: testId })
});
const data = await response.json();
if (data.code === 0 && data.data) {
const result = data.data;
const isSuccess = result.success;
resultDiv.className = 'test-result show ' + (isSuccess ? 'success' : 'error');
let messagesHtml = '';
if (result.messages && result.messages.length > 0) {
messagesHtml = `<div style="margin-top:8px;"><strong>消息记录:</strong></div>`;
result.messages.forEach((msg, idx) => {
messagesHtml += `<div class="test-result-body" style="margin-top:4px;">[${idx+1}] ${JSON.stringify(msg, null, 2)}</div>`;
});
}
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${isSuccess ? '✅ 连接成功' : '❌ 连接失败'}</span>
<span>${result.latency}ms</span>
</div>
${messagesHtml}
${result.error ? `<div style="color:#ff4d4f;margin-top:8px;"><strong>错误:</strong> ${result.error}</div>` : ''}
`;
btn.innerHTML = isSuccess ? '✓ 通过' : '✗ 失败';
btn.className = isSuccess ? 'btn btn-success btn-sm' : 'btn btn-danger btn-sm';
} else {
throw new Error(data.message || '测试执行失败');
}
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 执行错误</span></div><div>${e.message}</div>`;
btn.innerHTML = '✗ 失败';
btn.className = 'btn btn-danger btn-sm';
}
btn.disabled = false;
}
async function runAllWSTests() {
for (const item of wsTestCases) {
await runWSTest(item.id);
await new Promise(r => setTimeout(r, 1000)); // WS测试间隔更长
}
}
function clearAllWSResults() {
document.querySelectorAll('[id^="ws-result-"]').forEach(el => {
el.className = 'test-result';
el.innerHTML = '';
});
document.querySelectorAll('[id^="btn-ws-"]').forEach(el => {
el.innerHTML = '测试';
el.className = 'btn btn-primary btn-sm';
el.disabled = false;
});
}
// ============ 测试历史 ============
async function loadTestHistory() {
try {
const response = await fetch('/v1/admin/tests/history?limit=20', {
headers: { 'X-Admin-Token': apiKey }
});
const data = await response.json();
if (data.code === 0) {
renderTestHistory(data.data);
}
} catch (e) {
document.getElementById('test-history-list').innerHTML = '加载失败: ' + e.message;
}
}
function renderTestHistory(data) {
let html = '';
if (data.api_tests && data.api_tests.length > 0) {
html += `<div class="test-category">
<div class="test-category-title">API 测试历史</div>`;
data.api_tests.slice().reverse().forEach(test => {
const time = new Date(test.timestamp).toLocaleString();
html += `<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">
<span class="status-badge ${test.success ? 'status-success' : 'status-error'}">${test.success ? '通过' : '失败'}</span>
${test.name}
</div>
<div class="test-item-meta">${time} | ${test.latency}ms | HTTP ${test.status_code}</div>
</div>
</div>`;
});
html += '</div>';
}
if (data.ws_tests && data.ws_tests.length > 0) {
html += `<div class="test-category">
<div class="test-category-title">WebSocket 测试历史</div>`;
data.ws_tests.slice().reverse().forEach(test => {
const time = new Date(test.timestamp).toLocaleString();
html += `<div class="test-item">
<div class="test-item-info">
<div class="test-item-name">
<span class="status-badge ${test.success ? 'status-success' : 'status-error'}">${test.success ? '通过' : '失败'}</span>
${test.case_id}
</div>
<div class="test-item-meta">${time} | ${test.latency}ms</div>
</div>
</div>`;
});
html += '</div>';
}
if ((!data.api_tests || data.api_tests.length === 0) && (!data.ws_tests || data.ws_tests.length === 0)) {
html = '<p style="color:#999;text-align:center;padding:40px;">暂无测试历史</p>';
}
document.getElementById('test-history-list').innerHTML = html;
}
function clearTestHistory() {
document.getElementById('test-history-list').innerHTML = '<p style="color:#999;text-align:center;padding:40px;">暂无测试历史</p>';
}
// ============ 适配器管理 ============
let currentAdapters = [];
let editingAdapter = null;
async function loadAdapterList() {
try {
const response = await fetch('/v1/admin/adapters', {
headers: { 'X-Admin-Token': apiKey }
});
const data = await response.json();
if (data.code === 0 && data.data) {
currentAdapters = data.data.adapters || [];
renderAdapterList();
loadSourceStatus();
}
} catch (e) {
document.getElementById('adapter-list').innerHTML = '<p style="color:#ff4d4f;">加载失败: ' + e.message + '</p>';
}
}
function renderAdapterList() {
if (currentAdapters.length === 0) {
document.getElementById('adapter-list').innerHTML = '<p style="color:#999;">暂无适配器</p>';
return;
}
let html = '<table style="width:100%;border-collapse:collapse;">';
html += '<tr style="background:#fafafa;">';
html += '<th style="padding:12px;text-align:left;border:1px solid #f0f0f0;">适配器</th>';
html += '<th style="padding:12px;text-align:left;border:1px solid #f0f0f0;">类型</th>';
html += '<th style="padding:12px;text-align:left;border:1px solid #f0f0f0;">状态</th>';
html += '<th style="padding:12px;text-align:left;border:1px solid #f0f0f0;">描述</th>';
html += '<th style="padding:12px;text-align:center;border:1px solid #f0f0f0;">操作</th>';
html += '</tr>';
currentAdapters.forEach(adapter => {
const statusClass = adapter.status === 'active' ? 'status-success' :
adapter.status === 'standby' ? 'status-running' : '';
const statusText = adapter.status === 'active' ? '运行中' :
adapter.status === 'standby' ? '待机' : '已禁用';
html += `<tr>
<td style="padding:12px;border:1px solid #f0f0f0;">
<strong>${adapter.name}</strong>
<div style="font-size:11px;color:#999;">v${adapter.version}</div>
</td>
<td style="padding:12px;border:1px solid #f0f0f0;text-transform:uppercase;">${adapter.type}</td>
<td style="padding:12px;border:1px solid #f0f0f0;">
<span class="status-badge ${statusClass}">${statusText}</span>
</td>
<td style="padding:12px;border:1px solid #f0f0f0;color:#666;font-size:12px;">${adapter.description || '-'}</td>
<td style="padding:12px;border:1px solid #f0f0f0;text-align:center;">
<button class="btn btn-primary btn-sm" onclick="editAdapter('${adapter.name}')">配置</button>
</td>
</tr>`;
});
html += '</table>';
document.getElementById('adapter-list').innerHTML = html;
}
async function loadSourceStatus() {
try {
const response = await fetch('/v1/admin/source/status', {
headers: { 'X-API-Key': apiKey }
});
const data = await response.json();
if (data.code === 0 && data.data) {
let html = '<div class="stats-grid" style="grid-template-columns:repeat(2,1fr);">';
// 股票数据源
const stock = data.data.stock;
html += `<div class="stat-card">
<div style="font-size:12px;color:#666;margin-bottom:4px;">股票数据源</div>
<div style="font-size:20px;font-weight:bold;color:#1890ff;">${stock.active_source}</div>
<div style="font-size:11px;color:#999;margin-top:4px;">状态: ${stock.status}</div>
</div>`;
// 期货数据源
const futures = data.data.futures;
html += `<div class="stat-card">
<div style="font-size:12px;color:#666;margin-bottom:4px;">期货数据源</div>
<div style="font-size:20px;font-weight:bold;color:#1890ff;">${futures.active_source}</div>
<div style="font-size:11px;color:#999;margin-top:4px;">状态: ${futures.status}</div>
</div>`;
html += '</div>';
// 切换数据源按钮
html += '<div style="margin-top:16px;">';
html += '<button class="btn btn-primary btn-sm" onclick="switchToAdapter(&quot;custom&quot;)">切换到自定义数据源</button>';
html += '</div>';
document.getElementById('source-status').innerHTML = html;
}
} catch (e) {
document.getElementById('source-status').innerHTML = '<p style="color:#ff4d4f;">加载失败: ' + e.message + '</p>';
}
}
async function switchToAdapter(source) {
if (!confirm(`确定要切换到 ${source} 数据源吗`)) return;
try {
const response = await fetch('/v1/admin/source/switch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey
},
body: JSON.stringify({
asset_class: 'all',
source: source,
sync_backfill: false
})
});
const data = await response.json();
if (data.code === 0) {
alert('切换成功');
loadSourceStatus();
loadAdapterList();
} else {
alert('切换失败: ' + data.message);
}
} catch (e) {
alert('错误: ' + e.message);
}
}
function editAdapter(name) {
editingAdapter = currentAdapters.find(a => a.name === name);
if (!editingAdapter) return;
document.getElementById('modal-adapter-name').textContent = name;
document.getElementById('modal-adapter-enabled').value = editingAdapter.status !== 'disabled' ? 'true' : 'false';
// 生成配置字段
let configHtml = '';
if (editingAdapter.config) {
for (const [key, value] of Object.entries(editingAdapter.config)) {
configHtml += `<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:12px;color:#666;">${key}</label>
<input type="text" id="config-${key}" value="${value}" style="width:100%;padding:8px;border:1px solid #d9d9d9;border-radius:4px;">
</div>`;
}
}
document.getElementById('modal-config-fields').innerHTML = configHtml;
document.getElementById('adapter-config-modal').style.display = 'flex';
}
function closeAdapterModal() {
document.getElementById('adapter-config-modal').style.display = 'none';
editingAdapter = null;
}
async function saveAdapterConfig() {
if (!editingAdapter) return;
const enabled = document.getElementById('modal-adapter-enabled').value === 'true';
try {
// 1. 切换启用状态
const toggleResponse = await fetch('/v1/admin/adapters/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': apiKey
},
body: JSON.stringify({
name: editingAdapter.name,
enable: enabled
})
});
const toggleData = await toggleResponse.json();
if (toggleData.code !== 0) {
throw new Error(toggleData.message || '切换状态失败');
}
// 2. 保存配置
const newConfig = {};
if (editingAdapter.config) {
for (const key of Object.keys(editingAdapter.config)) {
const input = document.getElementById(`config-${key}`);
if (input) {
newConfig[key] = input.value;
}
}
}
const configResponse = await fetch('/v1/admin/adapters/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': apiKey
},
body: JSON.stringify({
name: editingAdapter.name,
config: newConfig
})
});
const configData = await configResponse.json();
if (configData.code !== 0) {
throw new Error(configData.message || '保存配置失败');
}
alert('保存成功');
closeAdapterModal();
loadAdapterList();
} catch (e) {
alert('保存失败: ' + e.message);
}
}
// ============ 快速测试运行全部 ============
async function runAllQuickTests() {
await quickTestStockKlines();
await quickTestStockSymbols();
await quickTestFuturesKlines();
await quickTestFuturesSymbols();
await quickTestHealth();
await quickTestSourceStatus();
}
function clearAllQuickResults() {
['test-result-stock-klines', 'test-result-stock-symbols',
'test-result-futures-klines', 'test-result-futures-symbols',
'test-result-health', 'test-result-source-status'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.className = 'test-result';
el.innerHTML = '';
}
});
}
// ============ 配置页面快速测试 ============
async function quickTestStockKlines() {
const symbol = document.getElementById('test-stock-symbol').value || '000001.SZ';
const freq = document.getElementById('test-stock-freq').value || '1d';
const resultDiv = document.getElementById('test-result-stock-klines');
resultDiv.className = 'test-result show';
resultDiv.innerHTML = '请求中...';
try {
// 固定测试日期2026年3月2日到2026年3月6日
const startStr = '20260302';
const endStr = '20260306';
const response = await fetch(`/v1/stock/klines/${symbol}?start=${startStr}&end=${endStr}&freq=${freq}`, {
headers: { 'X-API-Key': apiKey }
});
const data = await response.json();
resultDiv.className = 'test-result show ' + (data.code === 0 ? 'success' : 'error');
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${data.code === 0 ? '✅ 成功' : '❌ 失败'}</span>
<span>HTTP ${response.status}</span>
</div>
<div class="test-result-body">${JSON.stringify(data, null, 2)}</div>
`;
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 错误</span></div><div>${e.message}</div>`;
}
}
async function quickTestStockSymbols() {
const resultDiv = document.getElementById('test-result-stock-symbols');
resultDiv.className = 'test-result show';
resultDiv.innerHTML = '请求中...';
try {
const response = await fetch('/v1/stock/symbols?page=1&size=10', {
headers: { 'X-API-Key': apiKey }
});
const data = await response.json();
resultDiv.className = 'test-result show ' + (data.code === 0 ? 'success' : 'error');
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${data.code === 0 ? '✅ 成功' : '❌ 失败'}</span>
<span>HTTP ${response.status}</span>
</div>
<div class="test-result-body">${JSON.stringify(data, null, 2)}</div>
`;
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 错误</span></div><div>${e.message}</div>`;
}
}
async function quickTestFuturesKlines() {
const symbol = document.getElementById('test-futures-symbol').value || 'CU2504.SHFE';
const freq = document.getElementById('test-futures-freq').value || '1d';
const resultDiv = document.getElementById('test-result-futures-klines');
resultDiv.className = 'test-result show';
resultDiv.innerHTML = '请求中...';
try {
// 固定测试日期2026年3月2日到2026年3月6日
const startStr = '20260302';
const endStr = '20260306';
const response = await fetch(`/v1/futures/klines/${symbol}?start=${startStr}&end=${endStr}&freq=${freq}`, {
headers: { 'X-API-Key': apiKey }
});
const data = await response.json();
resultDiv.className = 'test-result show ' + (data.code === 0 ? 'success' : 'error');
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${data.code === 0 ? '✅ 成功' : '❌ 失败'}</span>
<span>HTTP ${response.status}</span>
</div>
<div class="test-result-body">${JSON.stringify(data, null, 2)}</div>
`;
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 错误</span></div><div>${e.message}</div>`;
}
}
async function quickTestFuturesSymbols() {
const resultDiv = document.getElementById('test-result-futures-symbols');
resultDiv.className = 'test-result show';
resultDiv.innerHTML = '请求中...';
try {
const response = await fetch('/v1/futures/symbols?page=1&size=10', {
headers: { 'X-API-Key': apiKey }
});
const data = await response.json();
resultDiv.className = 'test-result show ' + (data.code === 0 ? 'success' : 'error');
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${data.code === 0 ? '✅ 成功' : '❌ 失败'}</span>
<span>HTTP ${response.status}</span>
</div>
<div class="test-result-body">${JSON.stringify(data, null, 2)}</div>
`;
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 错误</span></div><div>${e.message}</div>`;
}
}
async function quickTestHealth() {
const resultDiv = document.getElementById('test-result-health');
resultDiv.className = 'test-result show';
resultDiv.innerHTML = '请求中...';
try {
const response = await fetch('/v1/admin/health');
const data = await response.json();
resultDiv.className = 'test-result show success';
resultDiv.innerHTML = `
<div class="test-result-header">
<span> 健康</span>
<span>HTTP ${response.status}</span>
</div>
<div class="test-result-body">${JSON.stringify(data, null, 2)}</div>
`;
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 错误</span></div><div>${e.message}</div>`;
}
}
async function quickTestSourceStatus() {
const resultDiv = document.getElementById('test-result-source-status');
resultDiv.className = 'test-result show';
resultDiv.innerHTML = '请求中...';
try {
const response = await fetch('/v1/admin/source/status', {
headers: { 'X-API-Key': apiKey }
});
const data = await response.json();
resultDiv.className = 'test-result show ' + (data.code === 0 ? 'success' : 'error');
resultDiv.innerHTML = `
<div class="test-result-header">
<span>${data.code === 0 ? '✅ 成功' : '❌ 失败'}</span>
<span>HTTP ${response.status}</span>
</div>
<div class="test-result-body">${JSON.stringify(data, null, 2)}</div>
`;
} catch (e) {
resultDiv.className = 'test-result show error';
resultDiv.innerHTML = `<div class="test-result-header"><span> 错误</span></div><div>${e.message}</div>`;
}
}
async function loadConfigList() {
try {
const response = await fetch('/v1/admin/config', {
headers: { 'X-Admin-Token': apiKey }
});
const data = await response.json();
if (data.code === 0) {
const configs = data.data.configs || [];
let html = '<table style="width:100%;border-collapse:collapse;">';
html += '<tr style="background:#fafafa;"><th style="padding:12px;text-align:left;border:1px solid #f0f0f0;">配置项</th><th style="padding:12px;text-align:left;border:1px solid #f0f0f0;">值</th><th style="padding:12px;text-align:left;border:1px solid #f0f0f0;">描述</th></tr>';
configs.forEach(cfg => {
html += `<tr>
<td style="padding:12px;border:1px solid #f0f0f0;">${cfg.key}</td>
<td style="padding:12px;border:1px solid #f0f0f0;"><code>${cfg.value}</code></td>
<td style="padding:12px;border:1px solid #f0f0f0;color:#666;">${cfg.description || '-'}</td>
</tr>`;
});
html += '</table>';
document.getElementById('config-list').innerHTML = html;
} else {
document.getElementById('config-list').innerHTML = '<p style="color:#999;">暂无配置数据</p>';
}
} catch (e) {
document.getElementById('config-list').innerHTML = '<p style="color:#ff4d4f;">加载失败: ' + e.message + '</p>';
}
}
// ============ 原有功能 ============
async function reloadConfig() {
try {
const response = await fetch('/v1/admin/system/reload', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
alert(data.message || '热加载完成');
} catch (e) {
alert('热加载失败: ' + e.message);
}
}
async function restartService() {
if (confirm('确定要重启服务吗?')) {
try {
const response = await fetch('/v1/admin/system/restart', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
alert(data.message || '重启命令已发送');
} catch (e) {
alert('重启失败: ' + e.message);
}
}
}
async function loadSystemStatus() {
try {
const response = await fetch('/v1/admin/system/status');
const data = await response.json();
if (data.data) {
document.getElementById('stat-uptime').textContent = data.data.uptime;
document.getElementById('stat-threads').textContent = data.data.threads;
}
} catch (e) {
console.error('Failed to load system status:', e);
}
}
// 页面加载时获取状态
loadSystemStatus();
setInterval(loadSystemStatus, 30000);
</script>
</body>
</html>
'''
@app.get("/admin", response_class=HTMLResponse)
async def admin_page():
"""管理后台页面"""
return ADMIN_HTML
@app.get("/")
async def root():
"""根路径重定向到管理后台"""
return {"message": "Market Data Service API", "docs": "/docs", "admin": "/admin"}
if __name__ == "__main__":
import uvicorn
# 从环境变量或配置获取端口
port = settings.port or config.server.port
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=port,
reload=config.server.mode == "debug"
)