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.

1408 lines
61 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""主应用入口 - 对应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.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=["*"],
)
# 注册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)
# 管理后台页面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('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>
<!-- 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') || 'demo-api-key-2024';
// 页面切换增强在底部script中补充数据加载逻辑
const _originalShowPage = showPage;
showPage = function(pageName) {
_originalShowPage(pageName);
if (pageName === 'tests') {
loadAPITestList();
loadWSTestList();
} else if (pageName === 'config') {
loadConfigList();
} else if (pageName === 'adapters') {
loadAdapterList();
}
};
const _originalSwitchTestTab = switchTestTab;
switchTestTab = function(tab) {
_originalSwitchTestTab(tab);
if (tab === 'history') loadTestHistory();
};
// ============ 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;
});
}
// ============ 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;amazingdata&quot;)">切换到 AmazingData</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"
)