/** * 服务端 API:QMT 连接配置域(从 api.js 拆分,2026-08-31) * * 端点: * qmt-connections / add / update / remove / activate / set-default / test * active-host * * 热切换编排:激活/更新/删除连接导致生效地址变化时,同步 dataSource.setBaseUrl + marketFeed.setBaseUrl * (技术约束-008:按请求读取 baseUrl,改字段即生效) * * 2026-08-31:移除 subscribe-ws / unsubscribe-ws / tick-snapshot(架构演进后无人调用, * 订阅与快照统一由 QuoteSync/QuoteHub 内部处理,不再暴露为 HTTP 端点(R-015 迭代 13) */ import { getQmtConnections, getActiveQmtConnection, addQmtConnection, updateQmtConnection, removeQmtConnection, activateQmtConnection, setDefaultQmtConnection, } from '../settings.js'; /** QMT 连接端点方法表 */ export const QMT_METHODS = new Set([ 'qmt-connections', 'qmt-connections/add', 'qmt-connections/update', 'qmt-connections/remove', 'qmt-connections/activate', 'qmt-connections/set-default', 'qmt-connections/test', 'qmt-connections/active-host', 'qmt-connections/mcp-status', // R-020/迭代18:QMT MCP 状态(probe / 缓存) ]); /** 服务端代理测试连接:GET {baseUrl}/health(浏览器跨域规避,Q3 手动测试) */ async function testQmtConnection(ctx, args) { const baseUrl = String(args?.baseUrl ?? '').trim().replace(/\/+$/, ''); if (!/^https?:\/\//i.test(baseUrl)) { throw Object.assign(new Error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'), { code: 'qmt-invalid-url' }); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 15000); const startedAt = Date.now(); try { const res = await fetch(baseUrl + '/health', { signal: controller.signal }); const latencyMs = Date.now() - startedAt; let status = null; try { status = (await res.json())?.status ?? null; } catch { /* 非 JSON 响应按 status=null */ } return { ok: res.ok, latencyMs, httpStatus: res.status, status, reachable: res.ok }; } catch (err) { return { ok: false, latencyMs: Date.now() - startedAt, reachable: false, error: err.name === 'AbortError' ? '请求超时(15s)' : (err?.message ?? String(err)), }; } finally { clearTimeout(timer); } } /** * 处理 QMT 连接端点 * @param {string} method * @param {object} args * @param {object} runtime { manager, settings, dataSource, marketCache } */ export async function handleQmt(method, args, { ctx, settings, dataSource, marketFeed, qmtHealthMonitor, mcpManager }) { switch (method) { case 'qmt-connections': { const state = getQmtConnections(settings); return { ...state, active: getActiveQmtConnection(state) }; } case 'qmt-connections/add': return await addQmtConnection(settings, { name: args.name, baseUrl: args.baseUrl }); case 'qmt-connections/update': { const before = getQmtConnections(settings); const value = await updateQmtConnection(settings, args.id, { name: args.name, baseUrl: args.baseUrl }); // 编辑的是激活配置且地址变化 → 立即热切换(技术约束-008) if (before.activeId === args.id && value.baseUrl !== dataSource.baseUrl) { dataSource.setBaseUrl(value.baseUrl); marketFeed?.setBaseUrl(value.baseUrl); qmtHealthMonitor?.probe(); // 切换后立即补探健康 ctx?.logger?.info('[one-divine-lot] 激活配置地址已变更,数据源热切换 → ' + value.baseUrl); // R-020/迭代18:MCP 目标地址随激活地址变更重挂 mcpManager?.resync(value.baseUrl).catch(() => {}); } return value; } case 'qmt-connections/remove': { const result = await removeQmtConnection(settings, args.id); // 生效连接因删除发生变化 → 立即热切换(产品约束-005 删除边界) const afterState = getQmtConnections(settings); const active = getActiveQmtConnection(afterState); if (active && active.baseUrl !== dataSource.baseUrl) { dataSource.setBaseUrl(active.baseUrl); marketFeed?.setBaseUrl(active.baseUrl); qmtHealthMonitor?.probe(); // 切换后立即补探健康 ctx?.logger?.info('[one-divine-lot] 激活配置已删除,自动切换 → ' + active.name + ' (' + active.baseUrl + ')'); } // R-020/迭代18:激活已变(或列表清空)→ MCP 跟随新激活 / 卸载 mcpManager?.resync(active?.baseUrl).catch(() => {}); return { ...afterState, active, removedWasActive: result.removedWasActive, removedWasDefault: result.removedWasDefault }; } case 'qmt-connections/activate': { const state = await activateQmtConnection(settings, args.id); const active = getActiveQmtConnection(state); if (active && active.baseUrl !== dataSource.baseUrl) { dataSource.setBaseUrl(active.baseUrl); marketFeed?.setBaseUrl(active.baseUrl); qmtHealthMonitor?.probe(); // 切换后立即补探健康 ctx?.logger?.info('[one-divine-lot] QMT 连接激活: ' + active.name + ' (' + active.baseUrl + ')'); } // R-020/迭代18:激活切换 → MCP 实例跟随(卸旧挂新,url=新激活 baseUrl+/mcp) mcpManager?.resync(active?.baseUrl).catch(() => {}); return { ...state, active }; } case 'qmt-connections/set-default': return await setDefaultQmtConnection(settings, args.id); case 'qmt-connections/test': { // 支持传未保存的 baseUrl(表单先测后存),或传 id 用已存配置地址 let url = args.baseUrl; if (!url && args.id) { const state = getQmtConnections(settings); url = state.list.find((c) => c.id === args.id)?.baseUrl; } return await testQmtConnection(ctx, { baseUrl: url }); } case 'qmt-connections/active-host': { // R-005:返回激活配置的 host 与 ws 地址 const state = getQmtConnections(settings); const active = getActiveQmtConnection(state) ?? { baseUrl: dataSource.baseUrl, name: '(fallback)' }; const baseUrl = String(active.baseUrl ?? '').replace(/\/+$/, ''); let host = ''; let wsUrl = ''; try { const u = new URL(baseUrl); host = u.host; // 含端口,如 100.110.38.78:8610 wsUrl = 'ws://' + u.host + '/ws'; } catch { /* baseUrl 非法时返回空 */ } return { name: active.name, baseUrl, host, wsUrl, activeId: state.activeId ?? '' }; } case 'qmt-connections/mcp-status': { // R-020/迭代18:MCP 状态(缓存秒回;refresh=true 即时探测) if (!mcpManager) return { state: 'disabled', error: 'mcpManager 不可用', checkedAt: 0 }; if (args.refresh) return await mcpManager.probe(); return mcpManager.getStatus(); } default: throw Object.assign(new Error('unknown qmt method: ' + method), { code: 'not-found' }); } }