diff --git a/frontend/src/app/api/config/test/route.ts b/frontend/src/app/api/config/test/route.ts index 408c329..42d7c71 100644 --- a/frontend/src/app/api/config/test/route.ts +++ b/frontend/src/app/api/config/test/route.ts @@ -6,7 +6,40 @@ export async function POST(req: NextRequest) { if (!BACKEND_BASE) { return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 }); } - // 新后端暂无统一 /config/test;先返回未实现 - const body = await req.text().catch(() => ''); - return Response.json({ success: false, message: 'config/test 未实现', echo: body }, { status: 501 }); + + try { + const body = await req.json(); + const { type, data } = body; + + if (!type || !data) { + return new Response('请求体必须包含 type 和 data', { status: 400 }); + } + + // 将请求转发到 API Gateway + const targetUrl = `${BACKEND_BASE.replace(/\/$/, '')}/configs/test`; + + const backendRes = await fetch(targetUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ type, ...data }), // 转发时将 data 字段展开 + }); + + const backendResBody = await backendRes.text(); + + return new Response(backendResBody, { + status: backendRes.status, + headers: { + 'Content-Type': 'application/json', + }, + }); + + } catch (error: any) { + console.error('配置测试代理失败:', error); + return new Response(JSON.stringify({ success: false, message: error.message || '代理请求时发生未知错误' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } } diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx index eab4407..1bb12e8 100644 --- a/frontend/src/app/config/page.tsx +++ b/frontend/src/app/config/page.tsx @@ -26,6 +26,143 @@ import type { } from '@/types'; import { useDataSourcesConfig, updateDataSourcesConfig, useAnalysisTemplateSets, updateAnalysisTemplateSets } from '@/hooks/useApi'; +// ---- Helpers: pretty print nested JSON as YAML-like (braceless, unquoted) ---- +const MAX_REPARSE_DEPTH = 2; +function tryParseJson(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return input; + } +} +function parsePossiblyNestedJson(raw: string): unknown { + let current: unknown = raw; + for (let i = 0; i < MAX_REPARSE_DEPTH; i++) { + if (typeof current === 'string') { + const trimmed = current.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + current = tryParseJson(trimmed); + continue; + } + } + break; + } + return current; +} +function indentLines(text: string, indent: string): string { + return text.split('\n').map((line) => indent + line).join('\n'); +} +function looksLikeStructuredText(s: string): boolean { + const t = s.trim(); + // 具有成对的大括号,或常见的 Rust/reqwest 错误标识,判定为可结构化的调试输出 + return (t.includes('{') && t.includes('}')) || t.includes('DynamicTransportError') || t.includes('reqwest::Error'); +} +function prettyFormatByBraces(input: string): string { + let out = ''; + let indentLevel = 0; + const indentUnit = ' '; + let inString = false; + let stringQuote = ''; + for (let i = 0; i < input.length; i++) { + const ch = input[i]!; + const prev = i > 0 ? input[i - 1]! : ''; + if ((ch === '"' || ch === "'") && prev !== '\\') { + if (!inString) { + inString = true; + stringQuote = ch; + } else if (stringQuote === ch) { + inString = false; + } + out += ch; + continue; + } + if (inString) { + out += ch; + continue; + } + if (ch === '{' || ch === '[' || ch === '(') { + indentLevel += 1; + out += ch + '\n' + indentUnit.repeat(indentLevel); + continue; + } + if (ch === '}' || ch === ']' || ch === ')') { + indentLevel = Math.max(0, indentLevel - 1); + out += '\n' + indentUnit.repeat(indentLevel) + ch; + continue; + } + if (ch === ',') { + out += ch + '\n' + indentUnit.repeat(indentLevel); + continue; + } + if (ch === ':') { + out += ch + ' '; + continue; + } + out += ch; + } + return out; +} +function toPseudoYaml(value: unknown, indent: string = ''): string { + const nextIndent = indent + ' '; + if (value === null || value === undefined) { + return `${indent}null`; + } + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') { + const s = String(value); + const shouldPretty = looksLikeStructuredText(s) || s.includes('\n'); + if (shouldPretty) { + const pretty = looksLikeStructuredText(s) ? prettyFormatByBraces(s) : s; + return `${indent}|-\n${indentLines(pretty, nextIndent)}`; + } + return `${indent}${s}`; + } + if (Array.isArray(value)) { + if (value.length === 0) return `${indent}[]`; + return value.map((item) => { + const rendered = toPseudoYaml(item, nextIndent); + const lines = rendered.split('\n'); + if (lines.length === 1) { + return `${indent}- ${lines[0].trimStart()}`; + } + return `${indent}- ${lines[0].trimStart()}\n${lines.slice(1).map((l) => indent + ' ' + l.trimStart()).join('\n')}`; + }).join('\n'); + } + if (typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) return `${indent}{}`; + return keys.map((k) => { + const rendered = toPseudoYaml(obj[k], nextIndent); + const lines = rendered.split('\n'); + if (lines.length === 1) { + return `${indent}${k}: ${lines[0].trimStart()}`; + } + return `${indent}${k}:\n${lines.map((l) => l).join('\n')}`; + }).join('\n'); + } + // fallback + return `${indent}${String(value)}`; +} +function formatDetailsToYaml(details: string): string { + const parsed = parsePossiblyNestedJson(details); + if (typeof parsed === 'string') { + // 尝试再次解析(有些场景内层 message 也是 JSON 串) + const again = parsePossiblyNestedJson(parsed); + if (typeof again === 'string') { + return toPseudoYaml(again); + } + return toPseudoYaml(again); + } + return toPseudoYaml(parsed); +} + +const defaultUrls: Partial> = { + tushare: 'http://api.tushare.pro', + finnhub: 'https://finnhub.io/api/v1', + alphavantage: 'https://mcp.alphavantage.co/mcp', +}; + export default function ConfigPage() { // 从 Zustand store 获取全局状态 const { config, loading, error, setConfig } = useConfigStore(); @@ -47,7 +184,7 @@ export default function ConfigPage() { // 分析配置保存状态(状态定义在下方统一维护) // 测试结果状态 - const [testResults, setTestResults] = useState>({}); + const [testResults, setTestResults] = useState>({}); // 保存状态 const [saving, setSaving] = useState(false); @@ -368,8 +505,22 @@ export default function ConfigPage() { try { if (initialDataSources) { - await updateDataSourcesConfig(localDataSources); - await mutateDataSources(localDataSources, false); + // Create a deep copy to avoid mutating the local state directly + const finalDataSources = JSON.parse(JSON.stringify(localDataSources)); + for (const key in finalDataSources) { + const providerKey = key as DataSourceProvider; + const source = finalDataSources[providerKey]; + // If the URL is empty or null, and there is a default URL, use it + if (source && (source.api_url === null || source.api_url.trim() === '') && defaultUrls[providerKey]) { + source.api_url = defaultUrls[providerKey]; + } + } + + await updateDataSourcesConfig(finalDataSources); + // After saving, mutate the local SWR cache with the final data + // and also update the component's local state to reflect the change. + await mutateDataSources(finalDataSources, false); + setLocalDataSources(finalDataSources); } setSaveMessage('保存成功!'); } catch (e: any) { @@ -383,11 +534,18 @@ export default function ConfigPage() { const handleTest = async (type: string, data: any) => { try { const result = await testConfig(type, data); - setTestResults(prev => ({ ...prev, [type]: result })); + const success = !!result?.success; + const summary = typeof result?.message === 'string' && result.message.trim().length > 0 + ? result.message + : (success ? '测试成功' : '测试失败'); + setTestResults(prev => ({ ...prev, [type]: { success, summary } })); } catch (e: any) { - setTestResults(prev => ({ - ...prev, - [type]: { success: false, message: e.message } + // 结构化错误对象:{ summary, details? } + const summary: string = (e && typeof e === 'object' && 'summary' in e) ? String(e.summary) : (e?.message || '未知错误'); + const details: string | undefined = (e && typeof e === 'object' && 'details' in e) ? (e.details ? String(e.details) : undefined) : undefined; + setTestResults(prev => ({ + ...prev, + [type]: { success: false, summary, details } })); } }; @@ -402,6 +560,11 @@ export default function ConfigPage() { handleTest('finnhub', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled }); }; + const handleTestAlphaVantage = () => { + const cfg = localDataSources['alphavantage']; + handleTest('alphavantage', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled }); + }; + const handleReset = () => { if (initialDataSources) setLocalDataSources(initialDataSources); setTestResults({}); @@ -1058,7 +1221,8 @@ export default function ConfigPage() { [providerKey]: { ...item, api_url: v, provider: providerKey }, })); }} - placeholder={providerKey === 'tushare' ? 'https://api.tushare.pro' : 'https://...'} + placeholder={defaultUrls[providerKey] ?? 'https://...'} + disabled={providerKey === 'yfinance'} /> @@ -1069,7 +1233,33 @@ export default function ConfigPage() { {providerKey === 'finnhub' && ( )} + {providerKey === 'alphavantage' && ( + + )} + {testResults[providerKey] ? (() => { + const r = testResults[providerKey]!; + if (r.success) { + return ( +
+ {r.summary} +
+ ); + } + return ( +
+
测试失败
+ {r.details ? ( +
+ 查看详细错误(YAML) +
+{formatDetailsToYaml(r.details)}
+                                  
+
+ ) : null} +
+ ); + })() : null} ); })} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index aa7fc3a..da844df 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -294,9 +294,41 @@ export async function testConfig(type: string, data: unknown) { if (!res.ok) { try { const err = JSON.parse(text); - throw new Error(err?.message || text); + // 优先从标准字段中提取错误信息;同时分离 details + let message: string = err?.error || err?.message || ''; + let detailsStr = ''; + if (err?.details !== undefined) { + if (typeof err.details === 'string') { + detailsStr = err.details; + // details 可能是被 JSON 序列化的字符串,尝试解析一次以便还原内部结构 + try { + const parsed = JSON.parse(detailsStr); + if (typeof parsed === 'string') { + detailsStr = parsed; + } else if (parsed && typeof parsed === 'object' && 'message' in parsed) { + detailsStr = String((parsed as any).message); + } else { + detailsStr = JSON.stringify(parsed); + } + } catch { + // 忽略解析失败,保留原始 details 字符串 + } + } else { + try { + detailsStr = JSON.stringify(err.details); + } catch { + detailsStr = String(err.details); + } + } + } + const summary = message || `HTTP ${res.status}`; + const detailsOut = (detailsStr && detailsStr.length > 0) ? detailsStr : (text || undefined); + // 抛出结构化错误对象,供调用方精确展示(details 始终尽量携带原始响应体,便于前端美化) + throw { summary, details: detailsOut }; } catch { - throw new Error(text || `HTTP ${res.status}`); + // 无法解析为 JSON 时,仍然把原始文本作为 details 返回,保证前端可美化 + const fallback = text || `HTTP ${res.status}`; + throw { summary: fallback, details: text || undefined }; } } try { diff --git a/services/alphavantage-provider-service/src/api.rs b/services/alphavantage-provider-service/src/api.rs index e3996dc..d596814 100644 --- a/services/alphavantage-provider-service/src/api.rs +++ b/services/alphavantage-provider-service/src/api.rs @@ -2,16 +2,18 @@ use std::collections::HashMap; use axum::{ extract::State, response::Json, - routing::get, + routing::{get, post}, Router, }; use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress}; use crate::state::{AppState, ServiceOperationalStatus}; +use crate::api_test; pub fn create_router(app_state: AppState) -> Router { Router::new() .route("/health", get(health_check)) .route("/tasks", get(get_current_tasks)) + .route("/test", post(api_test::test_connection)) .with_state(app_state) } diff --git a/services/alphavantage-provider-service/src/main.rs b/services/alphavantage-provider-service/src/main.rs index 2d54922..f5841ef 100644 --- a/services/alphavantage-provider-service/src/main.rs +++ b/services/alphavantage-provider-service/src/main.rs @@ -1,4 +1,5 @@ mod api; +mod api_test; mod config; mod error; mod mapping; diff --git a/services/api-gateway/src/api.rs b/services/api-gateway/src/api.rs index a132a3f..0644273 100644 --- a/services/api-gateway/src/api.rs +++ b/services/api-gateway/src/api.rs @@ -66,6 +66,7 @@ fn create_v1_router() -> Router { "/configs/data_sources", get(get_data_sources_config).put(update_data_sources_config), ) + .route("/configs/test", post(test_data_source_config)) // --- New Discover Routes --- .route("/discover-models/{provider_id}", get(discover_models)) .route("/discover-models", post(discover_models_preview)) @@ -208,6 +209,71 @@ async fn get_task_progress( } +// --- New Config Test Handler --- + +#[derive(Deserialize, Debug)] +struct TestConfigRequest { + r#type: String, + #[serde(flatten)] + data: serde_json::Value, +} + +/// [POST /v1/configs/test] +/// Forwards a configuration test request to the appropriate downstream service. +async fn test_data_source_config( + State(state): State, + Json(payload): Json, +) -> Result { + info!("test_data_source_config: type={}", payload.r#type); + + let target_service_url = match payload.r#type.as_str() { + "tushare" => state.config.provider_services.iter().find(|s| s.contains("tushare")), + "finnhub" => state.config.provider_services.iter().find(|s| s.contains("finnhub")), + "alphavantage" => state.config.provider_services.iter().find(|s| s.contains("alphavantage")), + _ => { + return Ok(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Unsupported config type" })), + ).into_response()); + } + }; + + if let Some(base_url) = target_service_url { + let client = reqwest::Client::new(); + let target_url = format!("{}/test", base_url.trim_end_matches('/')); + info!("Forwarding test request for '{}' to {}", payload.r#type, target_url); + + let response = client + .post(&target_url) + .json(&payload.data) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await?; + warn!("Downstream test for '{}' failed: status={} body={}", payload.r#type, status, error_text); + return Ok(( + StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), + Json(serde_json::json!({ + "error": "Downstream service returned an error", + "details": error_text, + })), + ).into_response()); + } + + let response_json: serde_json::Value = response.json().await?; + Ok((StatusCode::OK, Json(response_json)).into_response()) + } else { + warn!("No downstream service found for config type: {}", payload.r#type); + Ok(( + StatusCode::NOT_IMPLEMENTED, + Json(serde_json::json!({ "error": "No downstream service configured for this type" })), + ).into_response()) + } +} + + // --- Config API Handlers (Proxy to data-persistence-service) --- use common_contracts::config_models::{