diff --git a/app/backend/src/controllers/adminController.ts b/app/backend/src/controllers/adminController.ts index 118f437..f76f811 100644 --- a/app/backend/src/controllers/adminController.ts +++ b/app/backend/src/controllers/adminController.ts @@ -3,9 +3,35 @@ import prisma from '../config/database'; import { cache } from '../config/redis'; import logger from '../utils/logger'; import { DataSyncService } from '../services/dataSyncService'; +import { externalDataSourceService } from '../services/externalDataSourceService'; +import { getTradingDaysCount, getDailyStockCount } from '../utils/dateUtils'; const dataSyncService = new DataSyncService(); +// 内存任务存储 +interface Task { + id: string; + type: 'check' | 'buffer' | 'sync'; + status: 'pending' | 'running' | 'completed' | 'failed'; + progress?: number; + result?: any; + error?: string; + createdAt: Date; + completedAt?: Date; +} + +const tasks = new Map(); + +// 清理旧任务 +setInterval(() => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + for (const [id, task] of tasks.entries()) { + if (task.completedAt && task.completedAt < oneHourAgo) { + tasks.delete(id); + } + } +}, 60 * 60 * 1000); // 每小时清理一次 + // ========== 系统统计 ========== export const getSystemStats = async (_req: Request, res: Response) => { @@ -44,6 +70,15 @@ export const getSystemStats = async (_req: Request, res: Response) => { redisStatus = false; } + // 检查外部数据源连接 + let externalDataSourceStatus = false; + try { + const health = await externalDataSourceService.healthCheck(); + externalDataSourceStatus = health.status === 'healthy'; + } catch { + externalDataSourceStatus = false; + } + res.json({ code: 200, message: 'success', @@ -56,6 +91,7 @@ export const getSystemStats = async (_req: Request, res: Response) => { apiStatus: { database: databaseStatus, redis: redisStatus, + externalDataSource: externalDataSourceStatus, }, }, }); @@ -71,27 +107,94 @@ export const getSystemStats = async (_req: Request, res: Response) => { // ========== 数据检测 ========== +// 辅助函数:计算一年内可交易日期 +function getTradingDaysInRange(startDate: Date, endDate: Date): Date[] { + const tradingDays: Date[] = []; + const current = new Date(startDate); + + while (current <= endDate) { + const dayOfWeek = current.getDay(); + // 周一到周五为交易日(简化计算,未考虑节假日) + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + tradingDays.push(new Date(current)); + } + current.setDate(current.getDate() + 1); + } + + return tradingDays; +} + +// 辅助函数:格式化日期为 YYYYMMDD +function formatDateYYYYMMDD(date: Date): string { + return date.toISOString().split('T')[0].replace(/-/g, ''); +} + export const getDataCheck = async (_req: Request, res: Response) => { try { - const totalStocks = await prisma.stock.count(); - const totalSectors = await prisma.sector.count(); + // 获取本地数据库最新日期 + const localLatestQuote = await prisma.stockQuote.findFirst({ + orderBy: { quoteTime: 'desc' }, + select: { quoteTime: true }, + }); + const localLatestDate = localLatestQuote?.quoteTime; + + // 获取外部数据源最新日期 + let externalLatestDate: Date | null = null; + let externalStatus = 'disabled'; + let daysBehind = 0; - // 获取各类数据的统计 - const stockQuotesCount = await prisma.stockQuote.count(); - const sectorQuotesCount = await prisma.sectorQuote.count(); + if (externalDataSourceService.isEnabled()) { + try { + const nearestTradingDate = await externalDataSourceService.getNearestTradingDate(); + if (nearestTradingDate) { + externalLatestDate = new Date(nearestTradingDate); + externalStatus = 'connected'; + + // 计算本地落后天数 + if (localLatestDate) { + const diffTime = externalLatestDate.getTime() - localLatestDate.getTime(); + daysBehind = Math.max(0, Math.ceil(diffTime / (1000 * 60 * 60 * 24))); + } else { + daysBehind = -1; // 本地无数据 + } + } + } catch (error) { + logger.error('Failed to get external data source latest date:', error); + externalStatus = 'error'; + } + } + + // 计算数据同步状态 + let syncStatus: 'complete' | 'incomplete' | 'missing'; + if (externalStatus === 'disabled') { + syncStatus = 'incomplete'; + } else if (externalStatus === 'error') { + syncStatus = 'missing'; + } else if (daysBehind === 0) { + syncStatus = 'complete'; + } else if (daysBehind <= 3) { + syncStatus = 'incomplete'; + } else { + syncStatus = 'missing'; + } + + // 获取统计数据 + const stockCount = await prisma.stock.count(); + const totalSectors = await prisma.sector.count(); const klineCount = await prisma.stockKLine.count(); + const quoteCount = await prisma.stockQuote.count(); - const now = new Date().toISOString(); + const nowISO = new Date().toISOString(); const checks = [ { id: '1', name: '股票基础数据', type: 'stock', - total: totalStocks, - current: totalStocks, - lastUpdate: now, - status: totalStocks > 0 ? 'complete' : 'missing', + total: stockCount, + current: stockCount, + lastUpdate: nowISO, + status: stockCount > 0 ? 'complete' : 'missing', }, { id: '2', @@ -99,7 +202,7 @@ export const getDataCheck = async (_req: Request, res: Response) => { type: 'sector', total: totalSectors, current: totalSectors, - lastUpdate: now, + lastUpdate: nowISO, status: totalSectors > 0 ? 'complete' : 'missing', }, { @@ -108,27 +211,50 @@ export const getDataCheck = async (_req: Request, res: Response) => { type: 'kline', total: klineCount, current: klineCount, - lastUpdate: now, + lastUpdate: nowISO, status: klineCount > 0 ? 'complete' : 'missing', }, { id: '4', - name: '实时行情数据', + name: '本地行情数据', type: 'stock', - total: stockQuotesCount, - current: stockQuotesCount, - lastUpdate: now, - status: stockQuotesCount > 0 ? 'complete' : 'missing', + total: quoteCount, + current: quoteCount, + lastUpdate: localLatestDate?.toISOString() || nowISO, + status: localLatestDate ? 'complete' : 'missing', + details: `最新数据日期: ${localLatestDate?.toISOString().split('T')[0] || '无'}`, + }, + { + id: '5', + name: '数据同步状态', + type: 'index', + total: externalStatus === 'connected' ? 100 : 0, + current: externalStatus === 'connected' && daysBehind >= 0 ? Math.max(0, 100 - daysBehind * 10) : 0, + lastUpdate: nowISO, + status: syncStatus, + details: `数据源: ${externalStatus === 'connected' ? '已连接' : externalStatus === 'disabled' ? '未配置' : '连接失败'}, 本地最新: ${localLatestDate?.toISOString().split('T')[0] || '无'}, 数据源最新: ${externalLatestDate?.toISOString().split('T')[0] || '无'}, ${daysBehind === -1 ? '需要初始化' : daysBehind === 0 ? '已同步' : `落后 ${daysBehind} 天`}`, }, ]; res.json({ code: 200, message: 'success', - data: checks, + data: { + checks, + summary: { + stockCount, + quoteCount, + klineCount, + localLatestDate: localLatestDate?.toISOString().split('T')[0] || null, + externalLatestDate: externalLatestDate?.toISOString().split('T')[0] || null, + externalStatus, + daysBehind: daysBehind === -1 ? null : daysBehind, + syncStatus, + }, + }, }); } catch (error) { - logger.error('Failed to get data check:', error); + logger.error('Failed to get data检查:', error); res.status(500).json({ code: 500, message: '获取数据检查失败', @@ -141,6 +267,121 @@ export const runDataCheck = async (_req: Request, res: Response) => { try { const taskId = `check_${Date.now()}`; + // 创建任务 + const task: Task = { + id: taskId, + type: 'check', + status: 'running', + createdAt: new Date(), + }; + tasks.set(taskId, task); + + // 异步执行检查 + setImmediate(async () => { + try { + // 获取本地数据库最新日期 + const localLatestQuote = await prisma.stockQuote.findFirst({ + orderBy: { quoteTime: 'desc' }, + select: { quoteTime: true }, + }); + const localLatestDate = localLatestQuote?.quoteTime; + + // 获取外部数据源最新日期 + let externalLatestDate: Date | null = null; + let externalStatus = 'disabled'; + let daysBehind = 0; + + if (externalDataSourceService.isEnabled()) { + try { + // 尝试获取外部数据源的最新交易日期 + const nearestTradingDate = await externalDataSourceService.getNearestTradingDate(); + if (nearestTradingDate) { + externalLatestDate = new Date(nearestTradingDate); + externalStatus = 'connected'; + + // 计算本地落后天数 + if (localLatestDate) { + const diffTime = externalLatestDate.getTime() - localLatestDate.getTime(); + daysBehind = Math.max(0, Math.ceil(diffTime / (1000 * 60 * 60 * 24))); + } else { + daysBehind = -1; // 本地无数据 + } + } + } catch (error) { + logger.error('Failed to get external data source latest date:', error); + externalStatus = 'error'; + } + } + + // 计算数据同步状态 + let syncStatus: 'success' | 'warning' | 'error'; + if (externalStatus === 'disabled') { + syncStatus = 'warning'; + } else if (externalStatus === 'error') { + syncStatus = 'error'; + } else if (daysBehind === 0) { + syncStatus = 'success'; + } else if (daysBehind <= 3) { + syncStatus = 'warning'; + } else { + syncStatus = 'error'; + } + + // 获取统计数据 + const stockCount = await prisma.stock.count(); + const quoteCount = await prisma.stockQuote.count(); + + // 更新任务状态 + task.status = 'completed'; + task.completedAt = new Date(); + task.result = { + checks: [ + { + name: '股票基础数据', + status: stockCount > 0 ? 'success' : 'warning', + count: stockCount, + message: stockCount > 0 ? `已加载 ${stockCount} 只股票` : '暂无数据', + }, + { + name: '本地行情数据', + status: localLatestDate ? 'success' : 'warning', + count: quoteCount, + latestDate: localLatestDate?.toISOString().split('T')[0] || '无', + }, + { + name: '数据源连接状态', + status: externalStatus === 'connected' ? 'success' : externalStatus === 'disabled' ? 'warning' : 'error', + message: externalStatus === 'connected' ? '已连接' : externalStatus === 'disabled' ? '未配置外部数据源' : '连接失败', + }, + { + name: '数据同步状态', + status: syncStatus, + localLatestDate: localLatestDate?.toISOString().split('T')[0] || '无', + externalLatestDate: externalLatestDate?.toISOString().split('T')[0] || '无', + daysBehind: daysBehind === -1 ? '本地无数据' : `${daysBehind} 天`, + message: daysBehind === 0 ? '数据已同步' : daysBehind === -1 ? '需要初始化数据' : `落后 ${daysBehind} 天`, + }, + ], + summary: { + stockCount, + quoteCount, + localLatestDate: localLatestDate?.toISOString().split('T')[0] || null, + externalLatestDate: externalLatestDate?.toISOString().split('T')[0] || null, + externalStatus, + daysBehind: daysBehind === -1 ? null : daysBehind, + syncStatus, + }, + }; + + logger.info(`Data check task ${taskId} completed: local=${localLatestDate?.toISOString().split('T')[0]}, external=${externalLatestDate?.toISOString().split('T')[0]}, behind=${daysBehind}`); + } catch (error) { + logger.error(`Data check task ${taskId} failed:`, error); + task.status = 'failed'; + task.error = error instanceof Error ? error.message : 'Unknown error'; + task.completedAt = new Date(); + } + }); + res.json({ code: 200, message: 'success', @@ -321,18 +562,32 @@ export const calculateMomentum = async (_req: Request, res: Response) => { export const getSyncTask = async (req: Request, res: Response) => { try { const { taskId } = req.params; + const task = tasks.get(taskId); + + if (!task) { + res.status(404).json({ + code: 404, + message: '任务不存在或已过期', + data: null, + }); + return; + } - // 这里应该从缓存或数据库获取任务状态 res.json({ code: 200, message: 'success', data: { - id: taskId, - status: 'running', - progress: 50, - currentTask: '同步中...', - totalRecords: 1000, - processedRecords: 500, + id: task.id, + type: task.type, + status: task.status, + progress: task.progress ?? (task.status === 'completed' ? 100 : task.status === 'running' ? 50 : 0), + currentTask: task.type === 'check' ? '数据检查中...' : '同步中...', + totalRecords: task.result?.summary?.expectedTotalQuotes ?? 1000, + processedRecords: task.result?.summary?.actualTotalQuotes ?? 500, + result: task.result, + error: task.error, + createdAt: task.createdAt, + completedAt: task.completedAt, }, }); } catch (error) { @@ -603,3 +858,187 @@ export const updateDataRetention = async (req: Request, res: Response) => { data: null, }); }; + +// ========== 数据源管理 ========== + +// 内存存储数据源配置(实际项目中应该存储在数据库中) +const dataSources = new Map([ + ['marketDataService', { + id: 'marketDataService', + name: '统一数据服务', + type: 'custom', + url: process.env.EXTERNAL_DATA_SOURCE_URL || 'http://localhost:8080', + enabled: true, + syncInterval: 5, + status: 'connected', + }] +]); + +export const getDataSources = async (_req: Request, res: Response) => { + try { + // 检查外部数据源实际状态 + let externalStatus = 'disconnected'; + try { + const health = await externalDataSourceService.healthCheck(); + externalStatus = health.status === 'healthy' ? 'connected' : 'error'; + } catch { + externalStatus = 'error'; + } + + const sources = Array.from(dataSources.values()).map(source => ({ + ...source, + status: source.id === 'marketDataService' ? externalStatus : source.status, + })); + + res.json({ + code: 200, + message: 'success', + data: sources, + }); + } catch (error) { + logger.error('Failed to get data sources:', error); + res.status(500).json({ + code: 500, + message: '获取数据源列表失败', + data: null, + }); + } +}; + +export const updateDataSource = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { url, enabled } = req.body; + + const source = dataSources.get(id); + if (!source) { + res.status(404).json({ + code: 404, + message: '数据源不存在', + data: null, + }); + return; + } + + if (url !== undefined) { + source.url = url; + } + if (enabled !== undefined) { + source.enabled = enabled; + } + + dataSources.set(id, source); + + res.json({ + code: 200, + message: 'success', + data: source, + }); + } catch (error) { + logger.error('Failed to update data source:', error); + res.status(500).json({ + code: 500, + message: '更新数据源配置失败', + data: null, + }); + } +}; + +export const testDataSource = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + if (id === 'marketDataService' || id === 'custom') { + // 测试外部数据源连接 + try { + const health = await externalDataSourceService.healthCheck(); + if (health.status === 'healthy') { + res.json({ + code: 200, + message: 'success', + data: { success: true, message: '统一数据服务连接正常' }, + }); + } else { + res.json({ + code: 200, + message: 'success', + data: { success: false, message: '统一数据服务未启用或连接失败' }, + }); + } + } catch (error) { + res.json({ + code: 200, + message: 'success', + data: { success: false, message: '统一数据服务连接失败: ' + (error instanceof Error ? error.message : '未知错误') }, + }); + } + return; + } + + res.status(404).json({ + code: 404, + message: '数据源不存在', + data: null, + }); + } catch (error) { + logger.error('Failed to test data source:', error); + res.status(500).json({ + code: 500, + message: '测试数据源连接失败', + data: null, + }); + } +}; + +export const triggerSync = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + if (id !== 'marketDataService' && id !== 'custom') { + res.status(404).json({ + code: 404, + message: '数据源不存在', + data: null, + }); + return; + } + + // 创建同步任务 + const taskId = `sync_${Date.now()}`; + const task: Task = { + id: taskId, + type: 'sync', + status: 'running', + createdAt: new Date(), + }; + tasks.set(taskId, task); + + // 异步执行同步 + setImmediate(async () => { + try { + await dataSyncService.syncRealTimeQuotes(); + task.status = 'completed'; + task.completedAt = new Date(); + logger.info(`Sync task ${taskId} completed`); + } catch (error) { + logger.error(`Sync task ${taskId} failed:`, error); + task.status = 'failed'; + task.error = error instanceof Error ? error.message : 'Unknown error'; + task.completedAt = new Date(); + } + }); + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Failed to trigger sync:', error); + res.status(500).json({ + code: 500, + message: '触发同步失败', + data: null, + }); + } +}; diff --git a/app/backend/src/middleware/logger.ts b/app/backend/src/middleware/logger.ts index 88e3836..1b0cced 100644 --- a/app/backend/src/middleware/logger.ts +++ b/app/backend/src/middleware/logger.ts @@ -4,10 +4,26 @@ import logger from '../utils/logger'; // 请求日志中间件 export function requestLogger(req: Request, res: Response, next: NextFunction): void { const start = Date.now(); + const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // 在请求对象上附加 requestId + (req as any).requestId = requestId; + + logger.info(`[${requestId}] Request Started`, { + method: req.method, + url: req.url, + ip: req.ip, + userAgent: req.get('user-agent'), + userId: req.user?.id, + params: req.params, + query: req.query, + body: req.method !== 'GET' ? JSON.stringify(req.body).substring(0, 500) : undefined, + }); res.on('finish', () => { const duration = Date.now() - start; const logData = { + requestId, method: req.method, url: req.url, status: res.statusCode, @@ -18,9 +34,9 @@ export function requestLogger(req: Request, res: Response, next: NextFunction): }; if (res.statusCode >= 400) { - logger.warn('HTTP Request', logData); + logger.warn(`[${requestId}] Request Failed`, logData); } else { - logger.info('HTTP Request', logData); + logger.info(`[${requestId}] Request Completed`, logData); } }); diff --git a/app/backend/src/routes/adminRoutes.ts b/app/backend/src/routes/adminRoutes.ts index 49de8b7..2c8bf21 100644 --- a/app/backend/src/routes/adminRoutes.ts +++ b/app/backend/src/routes/adminRoutes.ts @@ -10,6 +10,12 @@ router.use(authMiddleware); // ========== 系统统计 ========== router.get('/stats', adminController.getSystemStats); +// ========== 数据源管理 ========== +router.get('/data-sources', adminController.getDataSources); +router.put('/data-sources/:id', adminController.updateDataSource); +router.post('/data-sources/:id/test', adminController.testDataSource); +router.post('/data-sources/:id/sync', adminController.triggerSync); + // ========== 数据检测 ========== router.get('/data-check', adminController.getDataCheck); router.post('/data-check', adminController.runDataCheck); diff --git a/app/backend/src/services/externalDataSourceService.ts b/app/backend/src/services/externalDataSourceService.ts index c04b324..62998ec 100644 --- a/app/backend/src/services/externalDataSourceService.ts +++ b/app/backend/src/services/externalDataSourceService.ts @@ -220,25 +220,45 @@ export class ExternalDataSourceService { /** * 获取最近交易日 + * 使用 trading-dates 接口获取最近一个交易日 */ async getNearestTradingDate(date?: string): Promise { if (!this.enabled) { throw new Error('External data source is not enabled'); } - const params: Record = {}; - if (date) params.date = date; - + // 构造查询日期范围:从指定日期往前推 10 天 + const targetDate = date ? new Date(date) : new Date(); + const startDate = new Date(targetDate); + startDate.setDate(startDate.getDate() - 10); + + // 格式化为 YYYYMMDD + const formatDate = (d: Date) => d.toISOString().split('T')[0].replace(/-/g, ''); + const response = await axios.get( - `${this.baseUrl}/v1/stock/nearest-trading-date`, - { ...this.getRequestConfig(), params } + `${this.baseUrl}/v1/stock/trading-dates`, + { + ...this.getRequestConfig(), + params: { + start: formatDate(startDate), + end: formatDate(targetDate), + }, + } ); if (response.data.code !== 0) { throw new Error(response.data.message || 'Failed to get nearest trading date'); } - return response.data.data?.nearestTradingDate || null; + // 返回最后一个交易日(最近的) + const tradingDates = response.data.data?.trading_dates || []; + if (tradingDates.length === 0) { + return null; + } + + // 将 YYYYMMDD 转换为 YYYY-MM-DD + const lastDate = tradingDates[tradingDates.length - 1]; + return `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`; } /** diff --git a/app/backend/src/utils/dateUtils.ts b/app/backend/src/utils/dateUtils.ts new file mode 100644 index 0000000..df33932 --- /dev/null +++ b/app/backend/src/utils/dateUtils.ts @@ -0,0 +1,42 @@ +/** + * 日期工具函数 + */ + +/** + * 获取指定日期范围内的交易日数量(周一至周五) + */ +export function getTradingDaysCount(startDate: Date, endDate: Date): number { + let count = 0; + const current = new Date(startDate); + + while (current <= endDate) { + const dayOfWeek = current.getDay(); + // 周一到周五为交易日 + if (dayOfWeek >= 1 && dayOfWeek <= 5) { + count++; + } + current.setDate(current.getDate() + 1); + } + + return count; +} + +/** + * 判断是否为节假日(简化实现) + */ +export function isHoliday(date: Date): boolean { + // 这里可以实现更复杂的节假日判断逻辑 + // 暂时只排除周末 + const dayOfWeek = date.getDay(); + return dayOfWeek === 0 || dayOfWeek === 6; +} + +/** + * 获取指定日期的股票数量 + * 用于数据完整性检查 + */ +export async function getDailyStockCount(date: Date): Promise { + // 返回一个估算的每日股票数量 + // 实际应该查询历史数据表,这里使用一个合理的估算值 + return 5200; // A股大约5200只股票 +} diff --git a/app/src/admin/pages/Dashboard.tsx b/app/src/admin/pages/Dashboard.tsx index 2d9d3ad..f3553f7 100644 --- a/app/src/admin/pages/Dashboard.tsx +++ b/app/src/admin/pages/Dashboard.tsx @@ -18,7 +18,7 @@ interface SystemStats { dataCompleteness: number; lastSync: string; apiStatus: { - akshare: boolean; + externalDataSource: boolean; database: boolean; redis: boolean; }; @@ -50,7 +50,7 @@ export default function Dashboard() { setActivities([ { time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), action: '数据同步完成', detail: `共更新 ${data.totalStocks} 只股票数据` }, { time: '12:15', action: '系统检查', detail: `数据完整度 ${data.dataCompleteness}%` }, - { time: '10:00', action: '服务状态检查', detail: `AKShare: ${data.apiStatus.akshare ? '正常' : '异常'}` }, + { time: '10:00', action: '服务状态检查', detail: `外部数据源: ${data.apiStatus.externalDataSource ? '正常' : '异常'}` }, { time: '08:30', action: '缓存更新', detail: 'Redis 缓存已刷新' }, ]); } catch (error) { diff --git a/app/src/admin/pages/DataCheck.tsx b/app/src/admin/pages/DataCheck.tsx index d53b308..a3cfd75 100644 --- a/app/src/admin/pages/DataCheck.tsx +++ b/app/src/admin/pages/DataCheck.tsx @@ -18,7 +18,7 @@ import { Layers, Zap } from 'lucide-react'; -import { adminApi, createSyncTaskWebSocket, type DataCheckItem, type SyncTask } from '@/services/adminApi'; +import { adminApi, createSyncTaskWebSocket, type DataCheckItem, type DataCheckSummary, type SyncTask } from '@/services/adminApi'; import { Settings } from 'lucide-react'; interface BufferOptions { @@ -30,6 +30,7 @@ interface BufferOptions { export default function DataCheck() { const [dataStatus, setDataStatus] = useState([]); + const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [checking, setChecking] = useState(false); const [buffering, setBuffering] = useState(false); @@ -47,16 +48,26 @@ export default function DataCheck() { const wsRef = useRef(null); const checkIntervalRef = useRef(null); + const checkTimeoutRef = useRef(null); + const [checkTimeout, setCheckTimeout] = useState(false); // 获取数据状态 const fetchDataStatus = useCallback(async () => { try { - const data = await adminApi.getDataCheck(); - setDataStatus(data); + const response = await adminApi.getDataCheck(); + // 适配新的响应格式 + if (response.checks) { + setDataStatus(response.checks); + setSummary(response.summary || null); + } else { + // 兼容旧格式 + setDataStatus(response); + } } catch (error) { console.error('Failed to fetch data status:', error); // API 失败时显示空数据 setDataStatus([]); + setSummary(null); } }, []); @@ -113,28 +124,49 @@ export default function DataCheck() { // 执行数据检查 const handleCheckData = async () => { setChecking(true); + setCheckTimeout(false); + + // 设置30秒超时 + const TIMEOUT_MS = 30000; + const startTime = Date.now(); + try { const result = await adminApi.runDataCheck(); + // 轮询任务状态 const checkTask = async () => { try { + const elapsed = Date.now() - startTime; + + // 检查是否超时 + if (elapsed > TIMEOUT_MS) { + setChecking(false); + setCheckTimeout(true); + console.warn('数据检查超时'); + return; + } + const task = await adminApi.getSyncTask(result.taskId); if (task.status === 'completed') { setChecking(false); + setCheckTimeout(false); fetchDataStatus(); } else if (task.status === 'failed') { setChecking(false); + setCheckTimeout(false); } else { setTimeout(checkTask, 1000); } } catch (error) { setChecking(false); + setCheckTimeout(false); } }; checkTask(); } catch (error) { console.error('Check failed:', error); setChecking(false); + setCheckTimeout(false); } }; @@ -226,12 +258,12 @@ export default function DataCheck() { ); }; - // 计算统计数据 - const completionRate = dataStatus.length > 0 + // 计算统计数据(优先使用后端返回的 summary 数据) + const completionRate = summary?.completenessRate ?? (dataStatus.length > 0 ? Math.round((dataStatus.filter(d => d.status === 'complete').length / dataStatus.length) * 100) - : 0; + : 0); - const totalMissing = dataStatus.reduce((acc, item) => { + const totalMissing = summary?.missingQuotesCount ?? dataStatus.reduce((acc, item) => { if (item.status !== 'complete') { return acc + (item.total - item.current); } @@ -272,6 +304,25 @@ export default function DataCheck() { + {/* Timeout Warning */} + {checkTimeout && ( + +
+ +
+

数据检查超时

+

+ 检查任务仍在后台运行,请稍后刷新页面查看结果 +

+
+
+
+ )} + {/* Progress Card */} {(buffering || currentTask) && ( {/* Summary Cards */} -
- -
-
-

数据完整度

-

{completionRate}%

+ {summary && ( +
+ +
+
+

数据完整度

+

{summary.completenessRate}%

+
+
+ +
-
- +
+
-
-
-
-
- + - -
-
-

缺失数据条数

-

{totalMissing.toLocaleString()}

+ +
+
+

缺失数据条数

+

{summary.missingQuotesCount.toLocaleString()}

+
+
+ +
-
- +

+ {summary.missingQuotesCount > 0 ? '建议执行一键缓冲' : '数据完整无需缓冲'} +

+ + + +
+
+

一年内可交易日

+

{summary.tradingDaysCount} 天

+
+
+ +
-
-

- {totalMissing > 0 ? '建议执行一键缓冲' : '数据完整无需缓冲'} -

-
+

+ 参考日期: {summary.referenceDate}, 当日股票数: {summary.dailyStockCount}只 +

+ +
+ )} + {/* 详细统计信息 */} + {summary && ( -
-
-

缓冲范围

-

- {Math.ceil((new Date(bufferOptions.endDate).getTime() - new Date(bufferOptions.startDate).getTime()) / (1000 * 60 * 60 * 24))} 天 +

数据统计详情

+
+
+

预期数据条数

+

{summary.expectedTotalQuotes.toLocaleString()}

+
+
+

实际数据条数

+

{summary.actualTotalQuotes.toLocaleString()}

+
+
+

缺失数据条数

+

0 ? 'text-red-400' : 'text-green-400'}`}> + {summary.missingQuotesCount.toLocaleString()}

-
- +
+

数据完整度

+

= 90 ? 'text-green-400' : summary.completenessRate >= 50 ? 'text-yellow-400' : 'text-red-400'}`}> + {summary.completenessRate}% +

-

- {bufferOptions.startDate} 至 {bufferOptions.endDate} +

+ * 计算公式: 预期数据 = 单日股票数({summary.dailyStockCount}只) × 可交易日({summary.tradingDaysCount}天) = {summary.expectedTotalQuotes.toLocaleString()}条

-
+ )} {/* Quick Actions */}
diff --git a/app/src/admin/pages/DataSourceConfig.tsx b/app/src/admin/pages/DataSourceConfig.tsx index f129f16..54e7c47 100644 --- a/app/src/admin/pages/DataSourceConfig.tsx +++ b/app/src/admin/pages/DataSourceConfig.tsx @@ -20,35 +20,21 @@ import { } from 'lucide-react'; import { adminApi, type DataSourceConfig as DataSourceConfigType } from '@/services/adminApi'; -interface AKShareStatus { - connected: boolean; - version?: string; - supportedApis?: string[]; -} - -interface AKShareConfig { - baseUrl: string; -} +const MARKET_DATA_SERVICE_ID = 'marketDataService'; export default function DataSourceConfig() { const navigate = useNavigate(); const [sources, setSources] = useState([]); - const [akshareStatus, setAkshareStatus] = useState(null); - const [akshareConfig, setAkshareConfig] = useState({ baseUrl: 'http://localhost:8000' }); const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isEditingUrl, setIsEditingUrl] = useState(false); - const [editingUrl, setEditingUrl] = useState(''); - const [savingUrl, setSavingUrl] = useState(false); - // 自定义数据源编辑状态 - const [editingCustom, setEditingCustom] = useState(false); - const [customUrl, setCustomUrl] = useState('http://localhost:8000'); - const [customEnabled, setCustomEnabled] = useState(false); - const [savingCustom, setSavingCustom] = useState(false); + // 统一数据服务编辑状态 + const [editingService, setEditingService] = useState(false); + const [serviceUrl, setServiceUrl] = useState('http://localhost:8080'); + const [serviceEnabled, setServiceEnabled] = useState(false); + const [savingService, setSavingService] = useState(false); const [loading, setLoading] = useState(true); const [testing, setTesting] = useState(null); - const [syncing, setSyncing] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // 检查登录状态 @@ -68,54 +54,30 @@ export default function DataSourceConfig() { const fetchData = async () => { setLoading(true); try { - const [sourcesData, statusData, configData] = await Promise.allSettled([ - adminApi.getDataSources(), - adminApi.getAKShareStatus().catch(() => ({ connected: false })), - adminApi.getAKShareConfig().catch(() => ({ baseUrl: 'http://localhost:8000' })), - ]); - - if (sourcesData.status === 'fulfilled') { - setSources(sourcesData.value); - // 初始化自定义数据源状态 - const customSource = sourcesData.value.find(s => s.id === 'custom'); - if (customSource) { - setCustomUrl(customSource.url); - setCustomEnabled(customSource.enabled); - } - } else if (sourcesData.reason?.message?.includes('登录')) { - setIsLoggedIn(false); - localStorage.removeItem('token'); - localStorage.removeItem('user'); - } + const sourcesData = await adminApi.getDataSources(); + setSources(sourcesData); - if (statusData.status === 'fulfilled') { - setAkshareStatus(statusData.value); - } - - if (configData.status === 'fulfilled') { - setAkshareConfig(configData.value); + // 初始化统一数据服务状态 + const serviceSource = sourcesData.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom'); + if (serviceSource) { + setServiceUrl(serviceSource.url); + setServiceEnabled(serviceSource.enabled); } } catch (error: any) { console.error('Failed to fetch data:', error); if (error.message?.includes('登录')) { setIsLoggedIn(false); + localStorage.removeItem('token'); + localStorage.removeItem('user'); } + // 使用默认配置 setSources([ { - id: 'akshare', - name: 'AKShare 官方', - type: 'akshare', - url: akshareConfig.baseUrl, - enabled: true, - syncInterval: 5, - status: 'disconnected', - }, - { - id: 'custom', - name: '自定义数据源', + id: MARKET_DATA_SERVICE_ID, + name: '统一数据服务', type: 'custom', url: 'http://localhost:8080', - enabled: false, + enabled: true, syncInterval: 5, status: 'disconnected', } @@ -125,7 +87,7 @@ export default function DataSourceConfig() { } }; - const handleTestConnection = async (sourceId: string = 'akshare') => { + const handleTestConnection = async (sourceId: string = MARKET_DATA_SERVICE_ID) => { setTesting(sourceId); setMessage(null); @@ -133,15 +95,9 @@ export default function DataSourceConfig() { const result = await adminApi.testDataSource(sourceId); setMessage({ type: result.success ? 'success' : 'error', - text: `[${sourceId === 'custom' ? '自定义数据源' : 'AKShare'}] ${result.message}`, + text: `[统一数据服务] ${result.message}`, }); - // 如果是AKShare,刷新状态 - if (sourceId === 'akshare') { - const status = await adminApi.getAKShareStatus(); - setAkshareStatus(status); - } - // 刷新数据源列表 await fetchData(); } catch (error: any) { @@ -154,20 +110,6 @@ export default function DataSourceConfig() { } }; - const handleManualSync = async () => { - setSyncing(true); - setMessage(null); - - try { - const result = await adminApi.triggerSync('akshare'); - setMessage({ type: 'success', text: `同步任务已启动,任务ID: ${result.taskId}` }); - } catch (error: any) { - setMessage({ type: 'error', text: error.message || '同步失败' }); - } finally { - setSyncing(false); - } - }; - const handleToggleSource = async (sourceId: string) => { const source = sources.find(s => s.id === sourceId); if (!source) return; @@ -179,86 +121,59 @@ export default function DataSourceConfig() { setSources(prev => prev.map(s => s.id === sourceId ? { ...s, enabled: newEnabled } : s )); - setMessage({ type: 'success', text: `${source.name} 已${newEnabled ? '启用' : '禁用'}` }); + setCustomEnabled(newEnabled); + setServiceEnabled(newEnabled); + setMessage({ type: 'success', text: `统一数据服务已${newEnabled ? '启用' : '禁用'}` }); } catch (error: any) { setMessage({ type: 'error', text: error.message || '操作失败' }); } }; - // 开始编辑 URL - const handleStartEditUrl = () => { - setEditingUrl(akshareConfig.baseUrl); - setIsEditingUrl(true); - }; - - // 取消编辑 URL - const handleCancelEditUrl = () => { - setIsEditingUrl(false); - setEditingUrl(''); - }; - - // 保存 URL - const handleSaveUrl = async () => { - if (!editingUrl.trim()) { - setMessage({ type: 'error', text: '请输入有效的 URL' }); - return; - } - - setSavingUrl(true); - try { - await adminApi.updateAKShareConfig({ baseUrl: editingUrl.trim() }); - setAkshareConfig(prev => ({ ...prev, baseUrl: editingUrl.trim() })); - setMessage({ type: 'success', text: 'AKShare 地址已更新,重启后端服务后生效' }); - setIsEditingUrl(false); - } catch (error: any) { - setMessage({ type: 'error', text: error.message || '保存失败' }); - } finally { - setSavingUrl(false); + // 开始编辑统一数据服务 + const handleStartEditService = () => { + const serviceSource = sources.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom'); + if (serviceSource) { + setServiceUrl(serviceSource.url); + setServiceEnabled(serviceSource.enabled); } + setEditingService(true); }; - // 开始编辑自定义数据源 - const handleStartEditCustom = () => { - const customSource = sources.find(s => s.id === 'custom'); - if (customSource) { - setCustomUrl(customSource.url); - setCustomEnabled(customSource.enabled); - } - setEditingCustom(true); + // 取消编辑统一数据服务 + const handleCancelEditService = () => { + setEditingService(false); }; - // 取消编辑自定义数据源 - const handleCancelEditCustom = () => { - setEditingCustom(false); - }; - - // 保存自定义数据源配置 - const handleSaveCustom = async () => { - if (!customUrl.trim()) { + // 保存统一数据服务配置 + const handleSaveService = async () => { + if (!serviceUrl.trim()) { setMessage({ type: 'error', text: '请输入有效的 URL' }); return; } - setSavingCustom(true); + setSavingService(true); try { - await adminApi.updateDataSource('custom', { - url: customUrl.trim(), - enabled: customEnabled, + const serviceSource = sources.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom'); + const sourceId = serviceSource?.id || MARKET_DATA_SERVICE_ID; + + await adminApi.updateDataSource(sourceId, { + url: serviceUrl.trim(), + enabled: serviceEnabled, }); setMessage({ type: 'success', - text: `自定义数据源已${customEnabled ? '启用' : '禁用'},地址: ${customUrl.trim()}` + text: `统一数据服务已${serviceEnabled ? '启用' : '禁用'},地址: ${serviceUrl.trim()}` }); - setEditingCustom(false); + setEditingService(false); await fetchData(); } catch (error: any) { setMessage({ type: 'error', text: error.message || '保存失败' }); } finally { - setSavingCustom(false); + setSavingService(false); } }; - const akshareSource = sources.find(s => s.type === 'akshare'); + const marketDataService = sources.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom'); // 加载中 if (loading) { @@ -296,7 +211,7 @@ export default function DataSourceConfig() {

数据源配置

-

管理 AKShare 数据源和自定义数据源连接

+

管理统一数据服务连接

@@ -322,7 +237,7 @@ export default function DataSourceConfig() { )} - {/* 自定义数据源配置卡片 */} + {/* 统一数据服务配置卡片 */}
-

自定义数据源

+

统一数据服务

- {customEnabled ? '已启用' : '未启用'} + {serviceEnabled ? '已启用' : '未启用'} - {customEnabled && ( - (优先使用) + {serviceEnabled && ( + (当前使用) )}
@@ -360,14 +275,14 @@ export default function DataSourceConfig() {
{/* Enable/Disable Switch */} {/* Edit Button */} - {!editingCustom ? ( + {!editingService ? ( ) : (
- {/* Custom Data Source Config */} - {editingCustom && ( + {/* Market Data Service Config */} + {editingService && ( 服务地址 setCustomUrl(e.target.value)} - placeholder="http://localhost:8000" + value={serviceUrl} + onChange={(e) => setServiceUrl(e.target.value)} + placeholder="http://localhost:8080" className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white outline-none focus:border-[#ff6b35]" />

- 输入Python数据服务的地址,例如: http://localhost:8000 或 http://192.168.1.100:8000 + 输入统一数据服务地址,例如: http://localhost:8080 或 http://192.168.1.100:8080

@@ -440,27 +355,27 @@ export default function DataSourceConfig() {
- )} - - {/* Test Button */} - - - {/* Sync Button */} - -
-
- - {/* AKShare URL Config */} -
-
-
- - 服务地址 -
- {!isEditingUrl ? ( -
- {akshareConfig.baseUrl} - -
- ) : ( -
- setEditingUrl(e.target.value)} - placeholder="http://localhost:8000" - className="bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-1.5 text-sm text-white w-64 outline-none focus:border-[#ff6b35]" - /> - - -
- )} -
-

- {customEnabled - ? '自定义数据源已启用,AKShare将作为备用数据源' - : '修改后需要重启后端服务才能生效。Docker 环境可使用 http://host.docker.internal:8000'} -

-
- - {/* Supported APIs */} - {akshareStatus?.supportedApis && akshareStatus.supportedApis.length > 0 && ( -
-

可用 API

-
- {akshareStatus.supportedApis.map(api => ( - - {api} - - ))} + {serviceUrl}
)} @@ -654,7 +404,7 @@ export default function DataSourceConfig() {

数据源列表

@@ -664,29 +414,20 @@ export default function DataSourceConfig() {
{source.name} - {source.enabled && source.id === 'custom' && ( + {source.enabled && ( - 优先 - - )} - {source.enabled && source.id === 'akshare' && !customEnabled && ( - 当前使用 )} @@ -718,7 +459,7 @@ export default function DataSourceConfig() {
@@ -727,10 +468,9 @@ export default function DataSourceConfig() {

数据源说明

  • 自定义数据源: 可配置为本地或远程 Python 数据服务,支持 IP 地址配置
  • -
  • AKShare 官方: 开源财经数据接口库,无需 API Key
  • -
  • 启用自定义数据源后,系统将优先使用自定义数据源获取数据
  • -
  • 自定义数据源不可用时,自动回退到 AKShare 数据源
  • +
  • 默认地址为 http://localhost:8080,可根据实际情况修改
  • 点击"测试连接"验证数据源是否可用
  • +
  • 修改配置后需要重启后端服务才能生效
diff --git a/app/src/services/adminApi.ts b/app/src/services/adminApi.ts index e90e98e..6acacb1 100644 --- a/app/src/services/adminApi.ts +++ b/app/src/services/adminApi.ts @@ -55,7 +55,7 @@ export interface AdminUser { export interface DataSourceConfig { id: string; name: string; - type: 'akshare' | 'tushare' | 'custom'; + type: 'custom'; url: string; apiKey?: string; enabled: boolean; @@ -76,6 +76,23 @@ export interface DataCheckItem { details?: string; } +// 数据检查结果汇总 +export interface DataCheckSummary { + tradingDaysCount: number; + referenceDate: string; + dailyStockCount: number; + expectedTotalQuotes: number; + actualTotalQuotes: number; + missingQuotesCount: number; + completenessRate: number; +} + +// 数据检查响应 +export interface DataCheckResponse { + checks: DataCheckItem[]; + summary: DataCheckSummary; +} + // 同步任务 export interface SyncTask { id: string; @@ -121,44 +138,10 @@ export const adminApi = { }); }, - // ========== AKShare 特定接口 ========== - - // 获取 AKShare 状态 - getAKShareStatus(): Promise<{ - connected: boolean; - version?: string; - supportedApis: string[]; - }> { - return adminRequest('/admin/akshare/status'); - }, - - // 获取 AKShare 配置 - getAKShareConfig(): Promise<{ - baseUrl: string; - timeout: number; - retryTimes: number; - rateLimit: number; - }> { - return adminRequest('/admin/akshare/config'); - }, - - // 更新 AKShare 配置 - updateAKShareConfig(config: { - baseUrl?: string; - timeout?: number; - retryTimes?: number; - rateLimit?: number; - }): Promise { - return adminRequest('/admin/akshare/config', { - method: 'PUT', - body: JSON.stringify(config), - }); - }, - // ========== 数据检测与缓冲 ========== // 获取数据完整性检查 - getDataCheck(): Promise { + getDataCheck(): Promise { return adminRequest('/admin/data-check'); },