|
|
"""测试服务 - 对应Go的internal/service/test.go"""
|
|
|
import asyncio
|
|
|
import json
|
|
|
from datetime import datetime, timedelta
|
|
|
from typing import List, Optional
|
|
|
from threading import RLock
|
|
|
|
|
|
import httpx
|
|
|
import websockets
|
|
|
|
|
|
from app.models import (
|
|
|
APITestListData, APITestCategory, APITestCase,
|
|
|
APITestRequest, APITestResult,
|
|
|
WSTestListData, WSTestCase, WSTestRequest, WSTestResult, WSMessage,
|
|
|
TestHistoryRequest, TestHistoryData
|
|
|
)
|
|
|
from app.core.logger import info, error
|
|
|
|
|
|
|
|
|
class TestService:
|
|
|
"""测试服务"""
|
|
|
|
|
|
def __init__(self):
|
|
|
self.lock = RLock()
|
|
|
self.api_history: List[APITestResult] = []
|
|
|
self.ws_history: List[WSTestResult] = []
|
|
|
self.history_size = 100
|
|
|
|
|
|
def get_api_test_list(self) -> APITestListData:
|
|
|
"""获取API测试列表"""
|
|
|
# 固定交易时间:2026年3月2日到2026年3月6日
|
|
|
test_start = datetime(2026, 3, 2)
|
|
|
test_end = datetime(2026, 3, 6)
|
|
|
|
|
|
categories = [
|
|
|
APITestCategory(
|
|
|
name="股票接口",
|
|
|
items=[
|
|
|
APITestCase(
|
|
|
id="stock_klines",
|
|
|
name="查询股票K线",
|
|
|
method="GET",
|
|
|
path="/v1/stock/klines/{symbol}",
|
|
|
description="查询指定股票的K线数据",
|
|
|
params={
|
|
|
"symbol": "000001.SZ",
|
|
|
"start": test_start.strftime("%Y%m%d"),
|
|
|
"end": test_end.strftime("%Y%m%d"),
|
|
|
"freq": "1d",
|
|
|
"adjust": "qfq"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="stock_symbols",
|
|
|
name="查询股票列表",
|
|
|
method="GET",
|
|
|
path="/v1/stock/symbols",
|
|
|
description="获取所有可用股票标的",
|
|
|
params={"page": "1", "size": "20"}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="stock_batch",
|
|
|
name="批量查询股票K线",
|
|
|
method="POST",
|
|
|
path="/v1/stock/klines/batch",
|
|
|
description="批量查询多只股票K线",
|
|
|
body={
|
|
|
"symbols": ["000001.SZ", "000002.SZ"],
|
|
|
"start": test_start.strftime("%Y%m%d"),
|
|
|
"end": test_end.strftime("%Y%m%d"),
|
|
|
"freq": "1d"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="stock_calendar",
|
|
|
name="查询交易日历",
|
|
|
method="GET",
|
|
|
path="/v1/stock/trading-dates",
|
|
|
description="查询股票交易日历",
|
|
|
params={
|
|
|
"start": test_start.strftime("%Y%m%d"),
|
|
|
"end": test_end.strftime("%Y%m%d")
|
|
|
}
|
|
|
),
|
|
|
]
|
|
|
),
|
|
|
APITestCategory(
|
|
|
name="期货接口",
|
|
|
items=[
|
|
|
APITestCase(
|
|
|
id="futures_klines",
|
|
|
name="查询期货K线",
|
|
|
method="GET",
|
|
|
path="/v1/futures/klines/{symbol}",
|
|
|
description="查询指定期货合约的K线数据",
|
|
|
params={
|
|
|
"symbol": "CU2504.SHFE",
|
|
|
"start": test_start.strftime("%Y%m%d"),
|
|
|
"end": test_end.strftime("%Y%m%d"),
|
|
|
"freq": "1d"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="futures_symbols",
|
|
|
name="查询期货列表",
|
|
|
method="GET",
|
|
|
path="/v1/futures/symbols",
|
|
|
description="获取所有可用期货标的",
|
|
|
params={"page": "1", "size": "20"}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="futures_batch",
|
|
|
name="批量查询期货K线",
|
|
|
method="POST",
|
|
|
path="/v1/futures/klines/batch",
|
|
|
description="批量查询多个期货合约K线",
|
|
|
body={
|
|
|
"symbols": ["CU2504.SHFE", "RB2505.SHFE"],
|
|
|
"start": test_start.strftime("%Y%m%d"),
|
|
|
"end": test_end.strftime("%Y%m%d"),
|
|
|
"freq": "1d"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="futures_contracts",
|
|
|
name="查询合约列表",
|
|
|
method="GET",
|
|
|
path="/v1/futures/contracts",
|
|
|
description="根据品种查询可交易合约",
|
|
|
params={"underlying": "CU", "exchange": "SHFE"}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="futures_calendar",
|
|
|
name="查询期货交易日历",
|
|
|
method="GET",
|
|
|
path="/v1/futures/trading-dates",
|
|
|
description="查询期货交易日历",
|
|
|
params={
|
|
|
"start": test_start.strftime("%Y%m%d"),
|
|
|
"end": test_end.strftime("%Y%m%d")
|
|
|
}
|
|
|
),
|
|
|
]
|
|
|
),
|
|
|
APITestCategory(
|
|
|
name="管理接口",
|
|
|
items=[
|
|
|
APITestCase(
|
|
|
id="admin_health",
|
|
|
name="健康检查",
|
|
|
method="GET",
|
|
|
path="/v1/admin/health",
|
|
|
description="检查服务健康状态",
|
|
|
params={}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_source_status",
|
|
|
name="数据源状态",
|
|
|
method="GET",
|
|
|
path="/v1/admin/source/status",
|
|
|
description="获取当前数据源状态",
|
|
|
params={}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_source_switch",
|
|
|
name="切换数据源",
|
|
|
method="POST",
|
|
|
path="/v1/admin/source/switch",
|
|
|
description="切换到指定数据源(amazingdata)",
|
|
|
body={
|
|
|
"asset_class": "all",
|
|
|
"source": "amazingdata",
|
|
|
"sync_backfill": False
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_system_status",
|
|
|
name="系统状态",
|
|
|
method="GET",
|
|
|
path="/v1/admin/system/status",
|
|
|
description="获取系统运行状态和资源使用情况",
|
|
|
params={}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_config_list",
|
|
|
name="查询配置列表",
|
|
|
method="GET",
|
|
|
path="/v1/admin/config",
|
|
|
description="获取所有配置项列表",
|
|
|
params={}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_config_update",
|
|
|
name="更新配置",
|
|
|
method="PUT",
|
|
|
path="/v1/admin/config",
|
|
|
description="更新系统配置",
|
|
|
body={
|
|
|
"key": "server.mode",
|
|
|
"value": "debug",
|
|
|
"description": "服务器运行模式"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_reload_config",
|
|
|
name="热加载配置",
|
|
|
method="POST",
|
|
|
path="/v1/admin/system/reload",
|
|
|
description="重新加载配置文件",
|
|
|
body={}
|
|
|
),
|
|
|
]
|
|
|
),
|
|
|
APITestCategory(
|
|
|
name="适配器管理",
|
|
|
items=[
|
|
|
APITestCase(
|
|
|
id="admin_adapters_list",
|
|
|
name="适配器列表",
|
|
|
method="GET",
|
|
|
path="/v1/admin/adapters",
|
|
|
description="获取所有数据源适配器列表",
|
|
|
params={}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_adapter_toggle",
|
|
|
name="切换适配器状态",
|
|
|
method="POST",
|
|
|
path="/v1/admin/adapters/toggle",
|
|
|
description="启用或禁用适配器",
|
|
|
body={
|
|
|
"name": "amazingdata",
|
|
|
"enable": True
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_adapter_config",
|
|
|
name="更新适配器配置",
|
|
|
method="PUT",
|
|
|
path="/v1/admin/adapters/config",
|
|
|
description="更新适配器配置参数",
|
|
|
body={
|
|
|
"name": "amazingdata",
|
|
|
"config": {
|
|
|
"timeout": "60"
|
|
|
}
|
|
|
}
|
|
|
),
|
|
|
]
|
|
|
),
|
|
|
APITestCategory(
|
|
|
name="数据同步接口",
|
|
|
items=[
|
|
|
APITestCase(
|
|
|
id="admin_data_sync_full",
|
|
|
name="全量数据同步",
|
|
|
method="POST",
|
|
|
path="/v1/admin/data/sync",
|
|
|
description="手动触发全量数据同步(基础+行情+财务)",
|
|
|
body={
|
|
|
"symbols": ["000001.SZ", "600519.SH"],
|
|
|
"sync_type": "full",
|
|
|
"start_date": "20240301",
|
|
|
"end_date": "20240310",
|
|
|
"asset_class": "stock"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_data_sync_base",
|
|
|
name="同步基础K线数据",
|
|
|
method="POST",
|
|
|
path="/v1/admin/data/sync",
|
|
|
description="仅同步基础K线数据(OHLCV)",
|
|
|
body={
|
|
|
"symbols": ["000001.SZ"],
|
|
|
"sync_type": "base",
|
|
|
"freq": "1d",
|
|
|
"start_date": "20240301",
|
|
|
"end_date": "20240310",
|
|
|
"asset_class": "stock"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_data_sync_quote",
|
|
|
name="同步行情指标数据",
|
|
|
method="POST",
|
|
|
path="/v1/admin/data/sync",
|
|
|
description="同步行情指标数据(均线/MACD/涨跌幅)",
|
|
|
body={
|
|
|
"symbols": ["000001.SZ"],
|
|
|
"sync_type": "quote",
|
|
|
"start_date": "20240301",
|
|
|
"end_date": "20240310",
|
|
|
"asset_class": "stock"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_data_sync_finance",
|
|
|
name="同步财务数据",
|
|
|
method="POST",
|
|
|
path="/v1/admin/data/sync",
|
|
|
description="同步财务数据(市值/股本/利润)",
|
|
|
body={
|
|
|
"symbols": ["000001.SZ"],
|
|
|
"sync_type": "finance",
|
|
|
"start_date": "20240301",
|
|
|
"end_date": "20240310",
|
|
|
"asset_class": "stock"
|
|
|
}
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_data_sync_incremental",
|
|
|
name="增量数据同步",
|
|
|
method="POST",
|
|
|
path="/v1/admin/data/sync/incremental",
|
|
|
description="触发增量同步(最近30天)",
|
|
|
body=["000001.SZ", "600519.SH"]
|
|
|
),
|
|
|
APITestCase(
|
|
|
id="admin_data_sync_futures",
|
|
|
name="期货数据同步",
|
|
|
method="POST",
|
|
|
path="/v1/admin/data/sync",
|
|
|
description="同步期货数据",
|
|
|
body={
|
|
|
"symbols": ["CU2504.SHFE"],
|
|
|
"sync_type": "full",
|
|
|
"start_date": "20240301",
|
|
|
"end_date": "20240310",
|
|
|
"asset_class": "futures"
|
|
|
}
|
|
|
),
|
|
|
]
|
|
|
),
|
|
|
APITestCategory(
|
|
|
name="测试管理",
|
|
|
items=[
|
|
|
APITestCase(
|
|
|
id="admin_test_history",
|
|
|
name="测试历史",
|
|
|
method="GET",
|
|
|
path="/v1/admin/tests/history",
|
|
|
description="获取测试执行历史记录",
|
|
|
params={"type": "api", "limit": "20"}
|
|
|
),
|
|
|
]
|
|
|
),
|
|
|
]
|
|
|
|
|
|
return APITestListData(categories=categories, base_url="")
|
|
|
|
|
|
async def run_api_test(self, base_url: str, req: APITestRequest) -> APITestResult:
|
|
|
"""执行API测试"""
|
|
|
# 获取测试用例
|
|
|
test_list = self.get_api_test_list()
|
|
|
|
|
|
test_case = None
|
|
|
for cat in test_list.categories:
|
|
|
for item in cat.items:
|
|
|
if item.id == req.id:
|
|
|
test_case = item
|
|
|
break
|
|
|
if test_case:
|
|
|
break
|
|
|
|
|
|
if not test_case:
|
|
|
raise ValueError(f"Test case not found: {req.id}")
|
|
|
|
|
|
# 合并参数
|
|
|
params = dict(test_case.params)
|
|
|
if req.params:
|
|
|
params.update(req.params)
|
|
|
|
|
|
# 构建URL
|
|
|
url = base_url + test_case.path
|
|
|
for k, v in params.items():
|
|
|
url = url.replace(f"{{{k}}}", str(v))
|
|
|
|
|
|
# 添加查询参数
|
|
|
if test_case.method == "GET" and params:
|
|
|
query_parts = []
|
|
|
for k, v in params.items():
|
|
|
if f"{{{k}}}" not in test_case.path:
|
|
|
query_parts.append(f"{k}={v}")
|
|
|
if query_parts:
|
|
|
url += "?" + "&".join(query_parts)
|
|
|
|
|
|
# 准备请求体
|
|
|
body = req.body if req.body is not None else test_case.body
|
|
|
|
|
|
# 执行请求
|
|
|
start_time = datetime.now()
|
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
try:
|
|
|
headers = {"X-API-Key": "test-api-key"}
|
|
|
|
|
|
if test_case.method == "GET":
|
|
|
response = await client.get(url, headers=headers, timeout=30)
|
|
|
elif test_case.method == "POST":
|
|
|
response = await client.post(
|
|
|
url, json=body, headers=headers, timeout=30
|
|
|
)
|
|
|
else:
|
|
|
raise ValueError(f"Unsupported method: {test_case.method}")
|
|
|
|
|
|
latency = int((datetime.now() - start_time).total_seconds() * 1000)
|
|
|
|
|
|
result = APITestResult(
|
|
|
id=int(datetime.now().timestamp()),
|
|
|
case_id=req.id,
|
|
|
name=test_case.name,
|
|
|
success=200 <= response.status_code < 300,
|
|
|
status_code=response.status_code,
|
|
|
latency=latency,
|
|
|
request={
|
|
|
"method": test_case.method,
|
|
|
"url": url,
|
|
|
"body": body
|
|
|
},
|
|
|
response=response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text,
|
|
|
timestamp=datetime.now()
|
|
|
)
|
|
|
|
|
|
self._add_api_history(result)
|
|
|
return result
|
|
|
|
|
|
except Exception as e:
|
|
|
latency = int((datetime.now() - start_time).total_seconds() * 1000)
|
|
|
result = APITestResult(
|
|
|
id=int(datetime.now().timestamp()),
|
|
|
case_id=req.id,
|
|
|
name=test_case.name,
|
|
|
success=False,
|
|
|
latency=latency,
|
|
|
request={
|
|
|
"method": test_case.method,
|
|
|
"url": url,
|
|
|
"body": body
|
|
|
},
|
|
|
error=str(e),
|
|
|
timestamp=datetime.now()
|
|
|
)
|
|
|
self._add_api_history(result)
|
|
|
return result
|
|
|
|
|
|
def get_ws_test_list(self) -> WSTestListData:
|
|
|
"""获取WebSocket测试列表"""
|
|
|
cases = [
|
|
|
WSTestCase(
|
|
|
id="ws_subscribe_stock",
|
|
|
name="订阅股票行情",
|
|
|
description="订阅单只股票实时行情",
|
|
|
action="subscribe",
|
|
|
symbols=["000001.SZ"]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_subscribe_futures",
|
|
|
name="订阅期货行情",
|
|
|
description="订阅单个期货合约实时行情",
|
|
|
action="subscribe",
|
|
|
symbols=["CU2504.SHFE"]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_subscribe_multi",
|
|
|
name="批量订阅",
|
|
|
description="同时订阅多个标的",
|
|
|
action="subscribe",
|
|
|
symbols=["000001.SZ", "000002.SZ", "CU2504.SHFE"]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_subscribe_many",
|
|
|
name="压力测试-大量订阅",
|
|
|
description="订阅大量标的测试性能",
|
|
|
action="subscribe",
|
|
|
symbols=[
|
|
|
"000001.SZ", "000002.SZ", "000063.SZ", "000333.SZ",
|
|
|
"000538.SZ", "000568.SZ", "000651.SZ", "000725.SZ",
|
|
|
"000768.SZ", "000858.SZ"
|
|
|
]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_unsubscribe",
|
|
|
name="取消订阅",
|
|
|
description="取消订阅标的",
|
|
|
action="unsubscribe",
|
|
|
symbols=["000001.SZ"]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_unsubscribe_all",
|
|
|
name="取消全部订阅",
|
|
|
description="取消所有已订阅标的",
|
|
|
action="unsubscribe",
|
|
|
symbols=["000001.SZ", "000002.SZ", "CU2504.SHFE"]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_heartbeat",
|
|
|
name="心跳检测",
|
|
|
description="测试WebSocket连接心跳",
|
|
|
action="subscribe",
|
|
|
symbols=["000001.SZ"]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_invalid_symbol",
|
|
|
name="无效标的测试",
|
|
|
description="测试订阅无效标的的错误处理",
|
|
|
action="subscribe",
|
|
|
symbols=["INVALID.CODE"]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_empty_symbols",
|
|
|
name="空订阅测试",
|
|
|
description="测试空标的列表的处理",
|
|
|
action="subscribe",
|
|
|
symbols=[]
|
|
|
),
|
|
|
WSTestCase(
|
|
|
id="ws_resubscribe",
|
|
|
name="重新订阅",
|
|
|
description="取消后重新订阅同一标的",
|
|
|
action="subscribe",
|
|
|
symbols=["000001.SZ"]
|
|
|
),
|
|
|
]
|
|
|
|
|
|
return WSTestListData(cases=cases, ws_url="")
|
|
|
|
|
|
async def run_ws_test(self, ws_url: str, req: WSTestRequest) -> WSTestResult:
|
|
|
"""执行WebSocket测试"""
|
|
|
# 获取测试用例
|
|
|
test_list = self.get_ws_test_list()
|
|
|
|
|
|
test_case = None
|
|
|
for item in test_list.cases:
|
|
|
if item.id == req.id:
|
|
|
test_case = item
|
|
|
break
|
|
|
|
|
|
if not test_case:
|
|
|
raise ValueError(f"Test case not found: {req.id}")
|
|
|
|
|
|
# 使用自定义标的
|
|
|
symbols = req.symbols if req.symbols else test_case.symbols
|
|
|
|
|
|
result = WSTestResult(
|
|
|
id=f"ws_{int(datetime.now().timestamp())}",
|
|
|
case_id=req.id,
|
|
|
timestamp=datetime.now(),
|
|
|
messages=[]
|
|
|
)
|
|
|
|
|
|
# 连接WebSocket
|
|
|
start_time = datetime.now()
|
|
|
|
|
|
try:
|
|
|
async with websockets.connect(
|
|
|
ws_url,
|
|
|
extra_headers={"X-API-Key": "test-api-key"}
|
|
|
) as ws:
|
|
|
result.latency = int((datetime.now() - start_time).total_seconds() * 1000)
|
|
|
result.success = True
|
|
|
|
|
|
# 发送订阅消息
|
|
|
msg = {
|
|
|
"action": test_case.action,
|
|
|
"symbols": symbols
|
|
|
}
|
|
|
await ws.send(json.dumps(msg))
|
|
|
|
|
|
# 等待响应(最多3条消息)
|
|
|
for _ in range(3):
|
|
|
try:
|
|
|
msg_data = await asyncio.wait_for(ws.recv(), timeout=5)
|
|
|
result.messages.append(WSMessage(
|
|
|
type="received",
|
|
|
data=json.loads(msg_data),
|
|
|
timestamp=datetime.now()
|
|
|
))
|
|
|
except asyncio.TimeoutError:
|
|
|
break
|
|
|
|
|
|
except Exception as e:
|
|
|
result.latency = int((datetime.now() - start_time).total_seconds() * 1000)
|
|
|
result.success = False
|
|
|
result.error = str(e)
|
|
|
|
|
|
self._add_ws_history(result)
|
|
|
return result
|
|
|
|
|
|
def get_test_history(self, req: TestHistoryRequest) -> TestHistoryData:
|
|
|
"""获取测试历史"""
|
|
|
with self.lock:
|
|
|
limit = req.limit or 20
|
|
|
|
|
|
api_tests = []
|
|
|
ws_tests = []
|
|
|
|
|
|
if not req.type or req.type == "api":
|
|
|
api_tests = self.api_history[-limit:]
|
|
|
|
|
|
if not req.type or req.type == "ws":
|
|
|
ws_tests = self.ws_history[-limit:]
|
|
|
|
|
|
return TestHistoryData(api_tests=api_tests, ws_tests=ws_tests)
|
|
|
|
|
|
def _add_api_history(self, result: APITestResult):
|
|
|
"""添加API测试历史"""
|
|
|
with self.lock:
|
|
|
self.api_history.append(result)
|
|
|
if len(self.api_history) > self.history_size:
|
|
|
self.api_history = self.api_history[-self.history_size:]
|
|
|
|
|
|
def _add_ws_history(self, result: WSTestResult):
|
|
|
"""添加WebSocket测试历史"""
|
|
|
with self.lock:
|
|
|
self.ws_history.append(result)
|
|
|
if len(self.ws_history) > self.history_size:
|
|
|
self.ws_history = self.ws_history[-self.history_size:]
|