Skip to main content

futucli/cmd/daemon/
doctor.rs

1mod bundle;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use futu_core::diagnostic_text::update_check_unavailable_action;
8use futu_core::market::qot_market_state_label as market_state_label;
9use serde::Serialize;
10use serde_json::{Value, json};
11
12use bundle::write_doctor_bundle;
13#[cfg(test)]
14pub(super) use bundle::{redact_sensitive_json_for_bundle, redact_sensitive_text_for_bundle};
15
16use crate::cmd::static_diag::summarize_static_status;
17use crate::cmd::version_check::{self, UpdateDecision, UpdateLevel};
18use crate::output::OutputFormat;
19
20/// 聚合 REST 诊断端点,输出一份可贴给排障方的一键体检单。
21pub async fn run_doctor(
22    rest_url: Option<&str>,
23    api_key: Option<&str>,
24    symbol: Option<&str>,
25    bundle_dir: Option<&Path>,
26    check_update: bool,
27    update_url: Option<&str>,
28    update_timeout_ms: u64,
29    output: OutputFormat,
30) -> Result<()> {
31    let base = super::resolve_rest_url(rest_url);
32    let client = futu_core::http_client::webpki_builder()
33        .timeout(std::time::Duration::from_secs(5))
34        .build()
35        .context("build reqwest client")?;
36    let mut endpoints = Vec::new();
37    endpoints.push(
38        fetch_doctor_endpoint(
39            &client,
40            &base,
41            "readyz",
42            reqwest::Method::GET,
43            "/readyz",
44            api_key,
45            None,
46        )
47        .await,
48    );
49    endpoints.push(
50        fetch_doctor_endpoint(
51            &client,
52            &base,
53            "admin-status",
54            reqwest::Method::GET,
55            "/api/admin/status",
56            api_key,
57            None,
58        )
59        .await,
60    );
61    endpoints.push(
62        fetch_doctor_endpoint(
63            &client,
64            &base,
65            "push-subscriber-info",
66            reqwest::Method::GET,
67            "/api/push-subscriber-info",
68            api_key,
69            None,
70        )
71        .await,
72    );
73    endpoints.push(
74        fetch_doctor_endpoint(
75            &client,
76            &base,
77            "sub-info",
78            reqwest::Method::GET,
79            "/api/sub-info",
80            api_key,
81            None,
82        )
83        .await,
84    );
85    endpoints.push(
86        fetch_doctor_endpoint(
87            &client,
88            &base,
89            "used-quota",
90            reqwest::Method::POST,
91            "/api/used-quota",
92            api_key,
93            Some(json!({})),
94        )
95        .await,
96    );
97    endpoints.push(
98        fetch_doctor_endpoint(
99            &client,
100            &base,
101            "history-kl-quota",
102            reqwest::Method::POST,
103            "/api/history-kl-quota",
104            api_key,
105            Some(json!({"c2s": {"b_get_detail": false}})),
106        )
107        .await,
108    );
109    endpoints.push(
110        fetch_doctor_endpoint(
111            &client,
112            &base,
113            "metrics",
114            reqwest::Method::GET,
115            "/metrics",
116            api_key,
117            None,
118        )
119        .await,
120    );
121    if let Some(symbol) = symbol {
122        endpoints.push(
123            fetch_doctor_endpoint(
124                &client,
125                &base,
126                "market-state",
127                reqwest::Method::POST,
128                "/api/market-state",
129                api_key,
130                Some(json!({"symbols": [symbol]})),
131            )
132            .await,
133        );
134        endpoints.push(
135            fetch_doctor_endpoint(
136                &client,
137                &base,
138                "quote-capability",
139                reqwest::Method::GET,
140                &format!("/api/quote-capability?symbol={symbol}"),
141                api_key,
142                None,
143            )
144            .await,
145        );
146    }
147
148    let mut findings = analyze_doctor(&endpoints, symbol);
149    if check_update {
150        findings.extend(fetch_update_check_finding(update_url, update_timeout_ms).await);
151    }
152    let report = DoctorReport {
153        rest_url: base,
154        symbol: symbol.map(str::to_string),
155        endpoints,
156        findings,
157    };
158    if let Some(dir) = bundle_dir {
159        write_doctor_bundle(&report, dir)?;
160        eprintln!("# doctor bundle written to {}", dir.display());
161    }
162    print_doctor_report(&report, output)
163}
164
165async fn fetch_update_check_finding(url: Option<&str>, timeout_ms: u64) -> Option<DoctorFinding> {
166    match version_check::check_for_update(env!("CARGO_PKG_VERSION"), url, timeout_ms).await {
167        Ok(decision) => update_check_finding_from_decision(&decision),
168        Err(err) => Some(DoctorFinding {
169            level: "INFO",
170            area: "update",
171            message: "version update check unavailable".to_string(),
172            action: format!(
173                "{err}; {}",
174                update_check_unavailable_action().text_with_code()
175            ),
176        }),
177    }
178}
179
180#[derive(Debug, Serialize)]
181pub(super) struct DoctorReport {
182    rest_url: String,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    symbol: Option<String>,
185    endpoints: Vec<DoctorEndpoint>,
186    findings: Vec<DoctorFinding>,
187}
188
189#[derive(Debug, Serialize)]
190pub(super) struct DoctorEndpoint {
191    pub(super) name: String,
192    pub(super) method: String,
193    pub(super) path: String,
194    pub(super) ok: bool,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub(super) http_status: Option<u16>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub(super) json: Option<Value>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub(super) text_preview: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub(super) error: Option<String>,
203}
204
205#[derive(Debug, Serialize)]
206pub(super) struct DoctorFinding {
207    pub(super) level: &'static str,
208    pub(super) area: &'static str,
209    pub(super) message: String,
210    pub(super) action: String,
211}
212
213async fn fetch_doctor_endpoint(
214    client: &reqwest::Client,
215    base: &str,
216    name: &str,
217    method: reqwest::Method,
218    path: &str,
219    api_key: Option<&str>,
220    json_body: Option<Value>,
221) -> DoctorEndpoint {
222    let method_text = method.as_str().to_string();
223    match super::request_raw(client, base, method, path, api_key, json_body).await {
224        Ok((status, body)) => {
225            let json = serde_json::from_str::<Value>(&body).ok();
226            let text_preview = if path == "/metrics" {
227                Some(select_metrics_preview(&body))
228            } else if json.is_none() {
229                Some(preview_text(&body, 1200))
230            } else {
231                None
232            };
233            DoctorEndpoint {
234                name: name.to_string(),
235                method: method_text,
236                path: path.to_string(),
237                ok: (200..300).contains(&status),
238                http_status: Some(status),
239                json,
240                text_preview,
241                error: None,
242            }
243        }
244        Err(err) => DoctorEndpoint {
245            name: name.to_string(),
246            method: method_text,
247            path: path.to_string(),
248            ok: false,
249            http_status: None,
250            json: None,
251            text_preview: None,
252            error: Some(err.to_string()),
253        },
254    }
255}
256
257pub(super) fn analyze_doctor(
258    endpoints: &[DoctorEndpoint],
259    symbol: Option<&str>,
260) -> Vec<DoctorFinding> {
261    let mut findings = Vec::new();
262    for ep in endpoints {
263        if !ep.ok {
264            findings.push(DoctorFinding {
265                level: if matches!(
266                    ep.name.as_str(),
267                    "metrics" | "used-quota" | "history-kl-quota"
268                ) {
269                    "WARN"
270                } else {
271                    "ERROR"
272                },
273                area: "endpoint",
274                message: format!(
275                    "{} {} unavailable{}",
276                    ep.method,
277                    ep.path,
278                    ep.http_status
279                        .map(|s| format!(" (HTTP {s})"))
280                        .unwrap_or_default()
281                ),
282                action: ep
283                    .error
284                    .clone()
285                    .unwrap_or_else(|| "查看 daemon 日志与 API key scope".to_string()),
286            });
287        }
288    }
289
290    if endpoint(endpoints, "readyz").is_some_and(|ep| ep.ok) {
291        findings.push(DoctorFinding {
292            level: "OK",
293            area: "readyz",
294            message: "REST readiness endpoint is reachable".to_string(),
295            action: "gateway dispatch path is ready enough for REST diagnostics".to_string(),
296        });
297    }
298
299    let market_state = endpoint_json(endpoints, "market-state").and_then(first_market_state);
300    let market_active = market_state.map(is_market_state_active);
301    if let Some(state) = market_state {
302        findings.push(DoctorFinding {
303            level: "INFO",
304            area: "market-state",
305            message: format!(
306                "{} market_state={} ({})",
307                symbol.unwrap_or("<symbol>"),
308                market_state_label(state),
309                state
310            ),
311            action: "用该状态辅助判断订阅无新 push 是闭市正常还是开市断流".to_string(),
312        });
313    }
314
315    if let Some(push_json) = endpoint_json(endpoints, "push-subscriber-info")
316        && let Some(push_health) = push_json.get("push_health")
317    {
318        if push_health
319            .get("push_stream_healthy")
320            .and_then(Value::as_bool)
321            == Some(false)
322        {
323            findings.push(DoctorFinding {
324                level: "ERROR",
325                area: "push",
326                message: "push_stream_healthy=false".to_string(),
327                action:
328                    "先看 consecutive_parse_errors / circuit breaker;必要时抓 daemon debug log"
329                        .to_string(),
330            });
331        }
332        if push_health
333            .get("backend_connected")
334            .and_then(Value::as_bool)
335            == Some(false)
336        {
337            findings.push(DoctorFinding {
338                level: "ERROR",
339                area: "push",
340                message: "backend_connected=false".to_string(),
341                action: "request path TCP 未连接;先确认 broker runtime 重连状态".to_string(),
342            });
343        }
344        if push_health
345            .get("last_push_received_at_ms")
346            .and_then(Value::as_i64)
347            == Some(0)
348        {
349            findings.push(DoctorFinding {
350                level: "WARN",
351                area: "push",
352                message: "daemon has not observed any backend push yet".to_string(),
353                action: "确认已订阅行情或交易推送;仅启动未订阅时可正常为 0".to_string(),
354            });
355        }
356    }
357
358    if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
359        && let Some(warning) = admin_json
360            .get("login")
361            .and_then(|login| login.get("credential"))
362            .and_then(|credential| credential.get("expiry_warning"))
363            .and_then(Value::as_str)
364    {
365        findings.push(DoctorFinding {
366            level: "WARN",
367            area: "auth",
368            message: warning.to_string(),
369            action: "优先执行 futucli daemon-reload;若已过期则用密码/SMS 重新认证".to_string(),
370        });
371    }
372
373    if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
374        && admin_json
375            .get("cache")
376            .and_then(|cache| cache.get("static_data"))
377            .is_some()
378        && let Ok(report) = summarize_static_status(admin_json)
379        && (!report.ready
380            || report.stock_list_sync_failed_total > 0
381            || report.stock_list_sync_recoverable_retry_total > 0)
382    {
383        let level = if report.ready { "INFO" } else { "WARN" };
384        findings.push(DoctorFinding {
385            level,
386            area: "static-data",
387            message: format!(
388                "stock-list readiness={} ready={} reason={}",
389                report.stock_list_readiness, report.ready, report.reason
390            ),
391            action: format!("{}; inspect with futucli static-status", report.action),
392        });
393    }
394
395    if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
396        && let Some(auth_refresh) = admin_json
397            .get("login")
398            .and_then(|login| login.get("auth_refresh"))
399    {
400        let proactive = auth_refresh
401            .get("client_sig_proactive_refresh_enabled")
402            .and_then(Value::as_bool)
403            .unwrap_or(false);
404        let reactive = auth_refresh
405            .get("client_sig_reactive_refresh_enabled")
406            .and_then(Value::as_bool)
407            .unwrap_or(false);
408        if proactive || reactive {
409            findings.push(DoctorFinding {
410                level: "INFO",
411                area: "auth",
412                message: format!(
413                    "client_sig refresh opt-in enabled: proactive={proactive}, reactive={reactive}"
414                ),
415                action: "确认这是有意开启的长跑恢复配置;默认关闭,建议先用非关键账户验证"
416                    .to_string(),
417            });
418        }
419    }
420
421    let subscribed_sub_types = endpoint_json(endpoints, "sub-info")
422        .map(collect_sub_types)
423        .unwrap_or_default();
424    let push_watermarks = endpoint_json(endpoints, "push-subscriber-info")
425        .and_then(|v| v.get("push_health"))
426        .map(collect_sub_type_watermarks)
427        .unwrap_or_default();
428    if subscribed_sub_types.is_empty() {
429        findings.push(DoctorFinding {
430            level: "INFO",
431            area: "subscription",
432            message: "no subscribed SubType found in /api/sub-info".to_string(),
433            action: "若期待行情 push,先执行 futucli sub 或 REST /api/subscribe".to_string(),
434        });
435    } else {
436        for sub_type in subscribed_sub_types {
437            if !push_watermarks.contains_key(&sub_type) {
438                findings.push(missing_sub_type_finding(sub_type, market_active, symbol));
439            }
440        }
441    }
442
443    if let Some(metrics) = endpoint(endpoints, "metrics")
444        && metrics.ok
445        && !metrics
446            .text_preview
447            .as_deref()
448            .unwrap_or_default()
449            .contains("futu_gateway_push_health_stream_healthy")
450    {
451        findings.push(DoctorFinding {
452            level: "WARN",
453            area: "metrics",
454            message: "/metrics reachable but push health metrics were not found".to_string(),
455            action: "确认 daemon 版本包含 v1.4.113 push-health Prometheus renderer".to_string(),
456        });
457    }
458
459    if let Some(capability_json) = endpoint_json(endpoints, "quote-capability") {
460        findings.extend(quote_capability_decision_findings(capability_json));
461    }
462
463    findings
464}
465
466pub(super) fn update_check_finding_from_decision(
467    decision: &UpdateDecision,
468) -> Option<DoctorFinding> {
469    match decision.level {
470        UpdateLevel::UpToDate => None,
471        UpdateLevel::Info => Some(DoctorFinding {
472            level: "INFO",
473            area: "update",
474            message: decision.message.clone(),
475            action: decision.action.clone(),
476        }),
477        UpdateLevel::Recommended => Some(DoctorFinding {
478            level: "WARN",
479            area: "update",
480            message: decision.message.clone(),
481            action: decision.action.clone(),
482        }),
483        UpdateLevel::Critical => Some(DoctorFinding {
484            level: "ERROR",
485            area: "update",
486            message: decision.message.clone(),
487            action: decision.action.clone(),
488        }),
489    }
490}
491
492fn endpoint<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a DoctorEndpoint> {
493    endpoints.iter().find(|ep| ep.name == name)
494}
495
496fn endpoint_json<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a Value> {
497    endpoint(endpoints, name).and_then(|ep| ep.json.as_ref())
498}
499
500fn missing_sub_type_finding(
501    sub_type: i64,
502    market_active: Option<bool>,
503    symbol: Option<&str>,
504) -> DoctorFinding {
505    match market_active {
506        Some(true) => DoctorFinding {
507            level: "WARN",
508            area: "subscription",
509            message: format!(
510                "SubType {sub_type} is subscribed but has no regular push watermark while {} is active",
511                symbol.unwrap_or("the reference symbol")
512            ),
513            action: "开市仍无该类型 push:确认订阅参数、backend push 通道;必要时 /api/admin/reload 后重订阅".to_string(),
514        },
515        Some(false) => DoctorFinding {
516            level: "INFO",
517            area: "subscription",
518            message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
519            action: "参考 symbol 当前非连续交易时段;闭市/午休/盘前结束时部分 SubType 无新数据通常正常".to_string(),
520        },
521        None => DoctorFinding {
522            level: "INFO",
523            area: "subscription",
524            message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
525            action: "未传 --symbol,无法区分闭市正常与开市断流;可加 --symbol HK.00700 或查 /api/market-state".to_string(),
526        },
527    }
528}
529
530fn collect_sub_types(value: &Value) -> BTreeSet<i64> {
531    let mut out = BTreeSet::new();
532    collect_i64_key(value, "sub_type", &mut out);
533    out
534}
535
536fn collect_sub_type_watermarks(push_health: &Value) -> BTreeMap<i64, i64> {
537    let mut out = BTreeMap::new();
538    if let Some(items) = push_health
539        .get("last_push_by_sub_type")
540        .and_then(Value::as_array)
541    {
542        for item in items {
543            if let (Some(sub_type), Some(at_ms)) = (
544                item.get("sub_type").and_then(Value::as_i64),
545                item.get("last_push_received_at_ms").and_then(Value::as_i64),
546            ) {
547                out.insert(sub_type, at_ms);
548            }
549        }
550    }
551    out
552}
553
554fn collect_i64_key(value: &Value, key: &str, out: &mut BTreeSet<i64>) {
555    match value {
556        Value::Object(map) => {
557            if let Some(v) = map.get(key).and_then(Value::as_i64) {
558                out.insert(v);
559            }
560            for child in map.values() {
561                collect_i64_key(child, key, out);
562            }
563        }
564        Value::Array(items) => {
565            for child in items {
566                collect_i64_key(child, key, out);
567            }
568        }
569        _ => {}
570    }
571}
572
573fn quote_capability_decision_findings(value: &Value) -> Vec<DoctorFinding> {
574    let Some(decisions) = value.get("decisions").and_then(Value::as_array) else {
575        return Vec::new();
576    };
577    decisions
578        .iter()
579        .filter_map(|decision| {
580            let allowed = decision.get("allowed").and_then(Value::as_bool)?;
581            if allowed {
582                return None;
583            }
584            let topic = decision
585                .get("topic")
586                .and_then(Value::as_str)
587                .unwrap_or("unknown");
588            let reason = decision
589                .get("reason")
590                .and_then(Value::as_str)
591                .unwrap_or("blocked by quote capability decision");
592            let source = decision
593                .get("source")
594                .and_then(Value::as_str)
595                .unwrap_or("unknown");
596            let freshness = decision
597                .get("freshness")
598                .and_then(Value::as_str)
599                .unwrap_or("unknown");
600            let action = decision
601                .get("action")
602                .and_then(Value::as_str)
603                .unwrap_or("inspect futucli quote-rights and quote-capability")
604                .to_string();
605            Some(DoctorFinding {
606                level: "WARN",
607                area: "quote-capability",
608                message: format!(
609                    "{topic} blocked: {reason} (source={source}, freshness={freshness})"
610                ),
611                action,
612            })
613        })
614        .collect()
615}
616
617fn first_market_state(value: &Value) -> Option<i32> {
618    let mut values = BTreeSet::new();
619    collect_i64_key(value, "market_state", &mut values);
620    values
621        .into_iter()
622        .next()
623        .and_then(|v| i32::try_from(v).ok())
624}
625
626fn is_market_state_active(state: i32) -> bool {
627    matches!(
628        state,
629        1 | 3 | 5 | 8 | 10 | 13 | 15 | 19 | 21 | 23 | 25 | 28 | 32 | 33 | 35 | 36 | 37
630    )
631}
632
633fn print_doctor_report(report: &DoctorReport, output: OutputFormat) -> Result<()> {
634    match output {
635        OutputFormat::Json => {
636            println!("{}", serde_json::to_string_pretty(report)?);
637        }
638        OutputFormat::Jsonl => {
639            println!("{}", serde_json::to_string(report)?);
640        }
641        OutputFormat::Table | OutputFormat::Markdown => {
642            println!("futucli doctor");
643            println!("REST: {}", report.rest_url);
644            if let Some(symbol) = &report.symbol {
645                println!("Symbol: {symbol}");
646            }
647            println!();
648            println!("Endpoints:");
649            for ep in &report.endpoints {
650                let status = ep
651                    .http_status
652                    .map(|s| s.to_string())
653                    .unwrap_or_else(|| "-".to_string());
654                let mark = if ep.ok { "OK" } else { "FAIL" };
655                println!("- [{mark}] {} {} HTTP {}", ep.method, ep.path, status);
656                if let Some(error) = &ep.error {
657                    println!("  error: {error}");
658                }
659            }
660            println!();
661            println!("Findings:");
662            if report.findings.is_empty() {
663                println!("- [OK] no actionable findings");
664            } else {
665                for finding in &report.findings {
666                    println!(
667                        "- [{}] {}: {}",
668                        finding.level, finding.area, finding.message
669                    );
670                    println!("  next: {}", finding.action);
671                }
672            }
673        }
674    }
675    Ok(())
676}
677
678fn select_metrics_preview(body: &str) -> String {
679    let selected: Vec<&str> = body
680        .lines()
681        .filter(|line| {
682            line.contains("futu_gateway_push_health")
683                || line.contains("futu_gateway_broker_tcp")
684                || line.contains("futu_metrics_registry")
685        })
686        .collect();
687    if selected.is_empty() {
688        preview_text(body, 1200)
689    } else {
690        selected.join("\n")
691    }
692}
693
694fn preview_text(body: &str, max_chars: usize) -> String {
695    body.chars().take(max_chars).collect()
696}